/*
* Copyright (C) 2016 rickyepoderi@yahoo.es
*
* This program 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.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.runnerup.export;
import android.annotation.TargetApi;
import android.content.ContentValues;
import android.database.sqlite.SQLiteDatabase;
import android.os.Build;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import org.runnerup.common.util.Constants;
import org.runnerup.export.format.RunalyzePost;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStreamWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Synchronizer for <em>Runalyze</em> server. See more info in the project <a href="https://runalyze.com/login.php">home page</a>.
*/
@TargetApi(Build.VERSION_CODES.FROYO)
public class RunalyzeSynchronizer extends DefaultSynchronizer {
/**
* Name of the runalyze synchronizer.
*/
public static final String NAME = "Runalyze";
/**
* Public https url for runalyze.
*/
public static final String PUBLIC_URL = "https://runalyze.com";
private long _id;
private String _password;
private String _username;
private String _url;
private boolean _version3;
private String _csrf_token;
private Map<String,Map<String,String>> _sports;
private Map<String,Map<String,String>> _types;
/**
* Empty constructor.
*/
public RunalyzeSynchronizer() {
_url = PUBLIC_URL;
_sports = new HashMap<>();
_types = new HashMap<>();
}
/**
* Initialzes the synchronizer with the information stored in the DB and passed.
* @param config The auth config stored in the ddbb
*/
@Override
public void init(ContentValues config) {
_id = config.getAsLong("_id");
String auth = config.getAsString(Constants.DB.ACCOUNT.AUTH_CONFIG);
//Log.d(getName(), "Initializing: " + auth);
if (auth != null) {
try {
JSONObject json = new JSONObject(auth);
_username = json.optString("username", null);
_password = json.optString("password", null);
_url = json.optString(Constants.DB.ACCOUNT.URL, null);
} catch (JSONException e) {
e.printStackTrace();
}
}
}
/**
* Features of the synchronizer (just upload)
* @param f The feature to check
* @return true of supported, false if not
*/
@Override
public boolean checkSupport(Feature f) {
switch (f) {
case UPLOAD:
return true;
default:
return false;
}
}
/**
* Getter for the synchronizer name
* @return The synchronizer name
*/
@Override
public String getName() {
return NAME;
}
/**
* return the id of the synchronizer
* @return The id
*/
@Override
public long getId() {
return _id;
}
/**
* Is the synchronizer configured?
* @return true if username, password and url is set
*/
@Override
public boolean isConfigured() {
return _username != null && _password != null && _url != null;
}
/**
* resets the synchronizer.
*/
@Override
public void reset() {
_username = null;
_password = null;
_url = PUBLIC_URL;
clearCookies();
}
/**
* Getter of the auth config with the extra url attribute.
* @return The auth config information
*/
@Override
public String getAuthConfig() {
JSONObject json = new JSONObject();
try {
json.put("username", _username);
json.put("password", _password);
json.put(Constants.DB.ACCOUNT.URL, _url);
} catch (JSONException e) {
e.printStackTrace();
}
return json.toString();
}
/**
* Method that writes to the debug the HTML returned by the runalyze calls.
* @param is The input stream of the connection
* @return The string with the
* @throws IOException
*/
protected String getResponse(InputStream is) throws IOException {
String result = null;
BufferedInputStream input = null;
ByteArrayOutputStream output = null;
try {
input = new BufferedInputStream(is);
output = new ByteArrayOutputStream();
byte[] buffer = new byte[1024]; // Adjust if you want
int bytesRead;
while ((bytesRead = input.read(buffer)) != -1) {
output.write(buffer, 0, bytesRead);
}
result =new String(output.toByteArray());
} finally {
try {
if (input != null) {
input.close();
}
} catch (IOException e) {
Log.e(getName(), "Error closing input", e);
}
try {
if (output != null) {
output.close();
}
} catch (IOException e) {
Log.e(getName(), "Error closing output", e);
}
}
Log.d(getName(), result);
return result;
}
/**
* The method was overriden because runalyze return two cookies on login. It seems only the
* last one should be kept. Original method stores (and then sends) the two of them.
* @param conn The connection after the login was made
*/
protected void getCookies(HttpURLConnection conn) {
Map<String, List<String>> headers = conn.getHeaderFields();
Map<String, String> tmpCookies = new HashMap<>();
for (Map.Entry<String, List<String>> e : headers.entrySet()) {
if ("Set-Cookie".equalsIgnoreCase(e.getKey())) {
for (String v : e.getValue()) {
Log.d(getName(), "cookie found = " + e);
if (v.indexOf(";") > 0) {
v = v.substring(0, v.indexOf(";"));
}
String cookieName = v.substring(0, v.indexOf("="));
String cookieValue = v.substring(v.indexOf("=") + 1, v.length());
tmpCookies.put(cookieName, cookieValue);
}
}
}
// the cookies are in tmpCookies
for (Map.Entry<String, String> e: tmpCookies.entrySet()) {
this.cookies.add(e.getKey() + "=" + e.getValue() + "; ");
}
// cookies
Log.d(getName(), "cookies=" + cookies);
}
protected void getCSRFToken(String response) {
Pattern pattern = Pattern.compile("<input type=\"hidden\" name=\"_csrf_token\" value=\"([^\"]+)\">");
Matcher matcher = pattern.matcher(response);
if (matcher.find()) {
_csrf_token = matcher.group(1);
Log.d(getName(), "CSRF token = " + _csrf_token);
} else {
Log.e(getName(), "Failed to get CSRF token");
}
}
/**
* Runalyze v3 needs to login with a valid session cookie. So we need to first request the
* login page and then perform the login. Besides this method in v2 returns just a 404 which
* let us to know it is a 2.x.
* @return The return code or -1 in case of strange error
*/
protected int prepareLogin() {
try {
URL url = new URL(_url + "/en/login");
Log.d(getName(), "URL=" + url);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setInstanceFollowRedirects(false);
conn.setDoOutput(true);
conn.connect();
clearCookies();
String response = getResponse(conn.getInputStream());
if (conn.getResponseCode() == 200) {
Log.d(getName(), response);
getCookies(conn);
getCSRFToken(response);
}
return conn.getResponseCode();
} catch (MalformedURLException e) {
Log.e(getName(), "Malformed URL", e);
} catch (ProtocolException e) {
Log.e(getName(), "Protocol Exception", e);
} catch (IOException e) {
Log.e(getName(), "IO Exception", e);
}
return -1;
}
/**
* Method that performs a silent login in runalyze and save the cookies for later use.
* @return The status
*/
protected Status login() {
OutputStreamWriter writer = null;
try {
Log.d(getName(), "Login enter");
URL url = new URL(_url + (_version3? "/en/login_check" : "/login.php"));
Log.d(getName(), "URL=" + url);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setInstanceFollowRedirects(false);
conn.setDoOutput(true);
addCookies(conn);
writer = new OutputStreamWriter(conn.getOutputStream());
writer.write((_version3? "_username=" : "username=" )
+ URLEncoder.encode(_username, "UTF-8")
+ (_version3? "&_password=" : "&password=")
+ URLEncoder.encode(_password, "UTF-8")
+ ((_version3) ? "&_target_path=" + URLEncoder.encode("/dashboard", "UTF-8") : "")
+ ((_version3) ? "&_csrf_token=" + URLEncoder.encode(_csrf_token, "UTF-8") : "")
+ "&submit=Login");
writer.flush();
conn.connect();
String response = getResponse(conn.getInputStream());
// v3 just redirects you again to /en/login so check it
if (conn.getResponseCode() == 302 && !response.contains("/en/login")) {
clearCookies();
getCookies(conn);
Log.d(getName(), "Login exit OK");
return Status.OK;
} else {
Log.e(getName(), "Invalid response code " + conn.getResponseCode());
Log.d(getName(), "Login exit ERROR");
return Status.ERROR;
}
} catch(MalformedURLException e) {
Log.e(getName(), "Malformed URL", e);
} catch(ProtocolException e) {
Log.e(getName(), "Protocol Exception", e);
} catch (IOException e) {
Log.e(getName(), "IO Exception", e);
} finally {
try {
if (writer != null) {
writer.close();
}
} catch (IOException e) {
Log.e(getName(), "Error closing writer", e);
}
}
Log.d(getName(), "Login exit ERROR");
return Status.ERROR;
}
/**
* Obtains the select with the name specified as argument.
* @param page The html page
* @param selectName The select name to search for
* @return The string of the select or null
*/
protected String obtainSelect(String page, String selectName) {
// find the start of the
Pattern selectStart = Pattern.compile("<[Ss][Ee][Ll][Ee][Cc][Tt] [^>]*[Nn][Aa][Mm][Ee]\\s*=\\s*[\"']" + selectName + "[\"'][^>]*>");
Pattern selectEnd = Pattern.compile("</[Ss][Ee][Ll][Ee][Cc][Tt]>");
Matcher selectStartMatcher = selectStart.matcher(page);
if (selectStartMatcher.find()) {
int start = selectStartMatcher.start();
Matcher selectEndMatcher = selectEnd.matcher(page);
if (selectEndMatcher.find(start)) {
int end = selectEndMatcher.end();
return page.substring(start, end);
}
}
return null;
}
/**
* Method that parses an option html tag and maps all the properties <em>name="value"</em>
* into a map.
* @param option The html option to parse
* @return The map of attributes in the option tag
*/
protected Map<String,String> parseOption(String option) {
Map<String,String> options = new HashMap<>();
Pattern pattern = Pattern.compile("([\\w-_]+)\\s*=\\s*(\"[^\"]*\"|\'[^\']*\')");
Matcher matcher = pattern.matcher(option);
while (matcher.find()) {
String name = matcher.group(1);
String value = matcher.group(2).substring(1, matcher.group(2).length() - 1);
options.put(name, value);
}
return options;
}
/**
* Obtains the name of an html option (the value in between).
* @param option The html option tag
* @return The name in the option
*/
protected String obtainName(String option) {
Pattern pattern = Pattern.compile(">([^<]+)<");
Matcher matcher = pattern.matcher(option);
if (matcher.find()) {
return matcher.group(1).trim();
}
return null;
}
/**
* Method that parses a page and searches for a select with a specified name. The select
* is parsed and a map is returned. The map has all the options keyed by the name and the
* values are another map with the attributes inside the option.
* @param page The html page
* @param selectName The name of the select to search for
* @return A map with all the values in the options
*/
protected Map<String,Map<String,String>> parseSelect(String page, String selectName) {
Map<String,Map<String,String>> map = new HashMap<>();
String select = obtainSelect(page, selectName);
if (select != null) {
Pattern optionStart = Pattern.compile("<[Oo][Pp][Tt][Ii][Oo][Nn] ");
Pattern optionEnd = Pattern.compile("</[Oo][Pp][Tt][Ii][Oo][Nn]>");
Matcher optionstartMatcher = optionStart.matcher(select);
while (optionstartMatcher.find()) {
int start = optionstartMatcher.start();
Matcher optionEndMatcher = optionEnd.matcher(select);
if (optionEndMatcher.find(start)) {
int end = optionEndMatcher.end();
String option = select.substring(start, end);
String name = obtainName(option);
Map<String, String> values = parseOption(option);
if (name != null && values != null && values.containsKey("value")) {
map.put(name, values);
}
}
}
}
return map;
}
/**
* Method that requests the <em>/activity/add</em> page and search for the selects of
* <em>sportid</em> and <em>typeid</em>. This way the synchronizer is aware of the sports
* and types defined in runalyze by the user. If no one is defined a default sportid will
* be sent.
*/
protected void doAdd() {
try {
URL add = new URL(_url + "/activity/add");
HttpURLConnection conn = (HttpURLConnection) add.openConnection();
conn.setRequestMethod("POST");
conn.setInstanceFollowRedirects(false);
conn.setDoOutput(true);
addCookies(conn);
conn.connect();
if (conn.getResponseCode() == 200) {
String response = getResponse(conn.getInputStream());
_sports = parseSelect(response, "sportid");
_types = parseSelect(response, "typeid");
}
Log.d(getName(), "sports=" + _sports);
Log.d(getName(), "types=" + _types);
} catch(MalformedURLException e) {
Log.e(getName(), "Could not parse sportid and typeid. Malformed URL", e);
} catch (ProtocolException e) {
Log.e(getName(), "Could not parse sportid and typeid. Protocol Exception", e);
} catch (IOException e) {
Log.e(getName(), "Could not parse sportid and typeid. IO Exception", e);
}
}
/**
* Connects to the runalyze server.
* @return The status
*/
@Override
public Status connect() {
if (!isConfigured()) {
// user/pass needed
Status s = Status.NEED_AUTH;
s.authMethod = AuthMethod.USER_PASS_URL;
return s;
} else if (!cookies.isEmpty()) {
// already logged in
// TODO: do a timestamp to check for inactivity (15min or something)
return Synchronizer.Status.OK;
} else {
Status s;
// do a login and check username and password
int rc = prepareLogin();
if (rc == 404) {
// it's a v2 version
Log.d(getName(), "Detected version 2.x");
_version3 = false;
} else if (rc == 200) {
// it's v3 version
Log.d(getName(), "Detected version 3.x");
_version3 = true;
} else {
// strange error
return Status.ERROR;
}
s = login();
if (Status.OK.equals(s)) {
doAdd();
}
return s;
}
}
/**
* Uploads the activity <em>mID</em> to runalyze.
* @param db The database
* @param mID The activity ID
* @return The status of the upload process
*/
@Override
public Status upload(SQLiteDatabase db, long mID) {
Status s;
if ((s = connect()) != Status.OK) {
return s;
}
OutputStreamWriter writer = null;
try {
RunalyzePost post = new RunalyzePost(db, _sports, _types);
URL url = new URL(_url + (_version3? "/activity/add" : "/call/call.Training.create.php"));
Log.d(getName(), "URL=" + url);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setInstanceFollowRedirects(false);
conn.setDoOutput(true);
addCookies(conn);
writer = new OutputStreamWriter(conn.getOutputStream());
post.export(mID, writer);
writer.flush();
conn.connect();
String response = getResponse(conn.getInputStream());
if (conn.getResponseCode() != 200 || !response.contains("The activity has been successfully created.")) {
Log.e(getName(), "Error code: " + conn.getResponseCode());
cookies.clear();
return Status.ERROR;
} else {
s = Status.OK;
s.activityId = mID;
return Status.OK;
}
} catch (Exception ex) {
Log.e(getName(), "Error importing into Runalyze: ", ex);
return Status.ERROR;
} finally {
if (writer != null) {
try {
writer.close();
} catch(IOException e) {
Log.e(getName(), "Error closing the writer", e);
}
}
}
}
}