/* * Copyright (C) 2013-2017 Stichting Akvo (Akvo Foundation) * * This file is part of Akvo Flow. * * Akvo Flow 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. * * Akvo Flow 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 Akvo Flow. If not, see <http://www.gnu.org/licenses/>. */ package org.akvo.flow.api; import android.content.Context; import android.net.Uri; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Base64; import org.akvo.flow.BuildConfig; import org.akvo.flow.data.preference.Prefs; import org.akvo.flow.domain.Survey; import org.akvo.flow.domain.SurveyedLocale; import org.akvo.flow.domain.response.SurveyedLocalesResponse; import org.akvo.flow.exception.HttpException; import org.akvo.flow.exception.HttpException.Status; import org.akvo.flow.serialization.form.SurveyMetaParser; import org.akvo.flow.serialization.response.SurveyedLocaleParser; import org.akvo.flow.util.HttpUtil; import org.akvo.flow.util.PlatformUtil; import org.akvo.flow.util.ServerManager; import org.akvo.flow.util.StatusUtil; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.URLEncoder; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.TimeZone; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import timber.log.Timber; public class FlowApi { //These values never change private final String apiKey; private final String phoneNumber; private final String imei; private final String androidId; private final String deviceIdentifier; private static final int ERROR_UNKNOWN = -1; private static final String HMAC_SHA_1_ALGORITHM = "HmacSHA1"; private static final String CHARSET_UTF8 = "UTF-8"; private static final String HTTPS_PREFIX = "https"; private static final String HTTP_PREFIX = "http"; private final String baseUrl; public FlowApi(Context context) { ServerManager serverManager = new ServerManager(context); this.baseUrl = serverManager.getServerBase(); this.apiKey = serverManager.getApiKey(); this.phoneNumber = StatusUtil.getPhoneNumber(context); this.imei = StatusUtil.getImei(context); this.androidId = PlatformUtil.getAndroidID(context); Prefs prefs = new Prefs(context); this.deviceIdentifier = prefs .getString(Prefs.KEY_DEVICE_IDENTIFIER, Prefs.DEFAULT_VALUE_DEVICE_IDENTIFIER); } public String getServerTime() throws IOException { String serverBase = baseUrl; if (serverBase.startsWith(HTTPS_PREFIX)) { serverBase = HTTP_PREFIX + serverBase.substring(HTTPS_PREFIX.length()); } final String url = buildServerTimeUrl(serverBase); String response = HttpUtil.httpGet(url); String time = ""; if (!TextUtils.isEmpty(response)) { JSONObject json; try { json = new JSONObject(response); time = json.getString("time"); } catch (JSONException e1) { Timber.e(e1, "Error fetching time"); } } return time; } @NonNull private String buildServerTimeUrl(@NonNull String serverBase) { Uri.Builder builder = Uri.parse(serverBase).buildUpon(); builder.appendPath(Path.TIME_CHECK); builder.appendQueryParameter(Param.TIMESTAMP, System.currentTimeMillis() + ""); return builder.build().toString(); } /** * Request the notifications GAE has ready for us, like the list of missing files. * * @return String body of the HTTP response * @throws Exception */ @Nullable public JSONObject getDeviceNotification(@NonNull String[] surveyIds) throws Exception { // Send the list of surveys we've got downloaded, getting notified of the deleted ones String url = buildDeviceNotificationUrl(baseUrl, surveyIds); String response = HttpUtil.httpGet(url); if (!TextUtils.isEmpty(response)) { return new JSONObject(response); } return null; } @NonNull private String buildDeviceNotificationUrl(@NonNull String serverBase, @NonNull String[] surveyIds) { Uri.Builder builder = Uri.parse(serverBase).buildUpon(); builder.appendPath(Path.DEVICE_NOTIFICATION); appendDeviceParams(builder); for (String id : surveyIds) { builder.appendQueryParameter(Param.FORM_ID, id); } return builder.build().toString(); } @NonNull public List<Survey> getSurveyHeader(@NonNull String surveyId) throws IOException { final String url = buildSurveyHeaderUrl(baseUrl, surveyId); String response = HttpUtil.httpGet(url); if (response != null) { return new SurveyMetaParser().parseList(response, true); } return Collections.emptyList(); } @NonNull private String buildSurveyHeaderUrl(@NonNull String serverBaseUrl, @NonNull String surveyId) { Uri.Builder builder = Uri.parse(serverBaseUrl).buildUpon(); builder.appendPath(Path.SURVEY_HEADER_SERVICE); builder.appendQueryParameter(Param.PARAM_ACTION, Param.VALUE_HEADER); builder.appendQueryParameter(Param.SURVEY_ID, surveyId); appendDeviceParams(builder); return builder.build().toString(); } public List<Survey> getSurveys() throws IOException { List<Survey> surveys = new ArrayList<>(); final String url = buildSurveysUrl(baseUrl); String response = HttpUtil.httpGet(url); if (response != null) { surveys = new SurveyMetaParser().parseList(response); } return surveys; } @NonNull private String buildSurveysUrl(@NonNull String serverBaseUrl) { Uri.Builder builder = Uri.parse(serverBaseUrl).buildUpon(); builder.appendPath(Path.SURVEY_LIST_SERVICE); builder.appendQueryParameter(Param.PARAM_ACTION, Param.VALUE_SURVEY); appendDeviceParams(builder); return builder.build().toString(); } /** * Notify GAE back-end that data is available * Sends a message to the service with the file name that was just uploaded * so it can start processing the file */ public int sendProcessingNotification(@NonNull String formId, @NonNull String action, @NonNull String fileName) { String url = buildProcessingNotificationUrl(baseUrl, formId, action, fileName); try { HttpUtil.httpGet(url); return HttpURLConnection.HTTP_OK; } catch (HttpException e) { Timber.e(e.getStatus() + " response for formId: " + formId); return e.getStatus(); } catch (Exception e) { Timber.e("GAE sync notification failed for file: " + fileName); return ERROR_UNKNOWN; } } @NonNull private String buildProcessingNotificationUrl(@NonNull String serverBaseUrl, @NonNull String formId, @NonNull String action, @NonNull String fileName) { Uri.Builder builder = Uri.parse(serverBaseUrl).buildUpon(); builder.appendPath(Path.NOTIFICATION); builder.appendQueryParameter(Param.PARAM_ACTION, action); builder.appendQueryParameter(Param.FORM_ID, formId); builder.appendQueryParameter(Param.FILENAME, fileName); appendDeviceParams(builder); return builder.build().toString(); } @Nullable public List<SurveyedLocale> getSurveyedLocales(long surveyGroup, @NonNull String timestamp) throws IOException { // Note: To compute the HMAC auth token, query params must be alphabetically ordered String url = buildSyncUrl(baseUrl, surveyGroup, timestamp); String response = HttpUtil.httpGet(url); if (response != null) { SurveyedLocalesResponse slRes = new SurveyedLocaleParser().parseResponse(response); if (slRes.getError() != null) { throw new HttpException(slRes.getError(), Status.MALFORMED_RESPONSE); } return slRes.getSurveyedLocales(); } return null; } @NonNull private String buildSyncUrl(@NonNull String serverBaseUrl, long surveyGroup, @NonNull String timestamp) { // Note: To compute the HMAC auth token, query params must be alphabetically ordered StringBuilder queryStringBuilder = new StringBuilder(); appendParam(queryStringBuilder, Param.ANDROID_ID, encodeParam(androidId)); appendParam(queryStringBuilder, Param.IMEI, encodeParam(imei)); appendParam(queryStringBuilder, Param.LAST_UPDATED, (!TextUtils.isEmpty(timestamp) ? timestamp : "0")); appendParam(queryStringBuilder, Param.PHONE_NUMBER, encodeParam(phoneNumber)); appendParam(queryStringBuilder, Param.SURVEY_GROUP, surveyGroup + ""); queryStringBuilder.append(Param.TIMESTAMP).append(Param.EQUALS).append(getTimestamp()); final String query = queryStringBuilder.toString(); return serverBaseUrl + "/" + Path.SURVEYED_LOCALE + "?" + query + Param.SEPARATOR + Param.HMAC + Param.EQUALS + getAuthorization(query); } private void appendParam(@NonNull StringBuilder queryStringBuilder, @NonNull String paramName, @NonNull String paramValue) { queryStringBuilder.append(paramName).append(Param.EQUALS).append(paramValue).append(Param .SEPARATOR); } private String encodeParam(@Nullable String param) { if (TextUtils.isEmpty(param)) { return ""; } try { return URLEncoder.encode(param, CHARSET_UTF8); } catch (UnsupportedEncodingException e) { Timber.e(e.getMessage()); return ""; } } @Nullable private String getAuthorization(@NonNull String query) { String authorization = null; try { SecretKeySpec signingKey = new SecretKeySpec(apiKey.getBytes(), HMAC_SHA_1_ALGORITHM); Mac mac = Mac.getInstance(HMAC_SHA_1_ALGORITHM); mac.init(signingKey); byte[] rawHmac = mac.doFinal(query.getBytes()); authorization = Base64.encodeToString(rawHmac, Base64.DEFAULT); } catch (@NonNull NoSuchAlgorithmException | InvalidKeyException e) { Timber.e(e.getMessage()); } return authorization; } private String getTimestamp() { SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss", Locale.US); dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); try { return URLEncoder.encode(dateFormat.format(new Date()), CHARSET_UTF8); } catch (UnsupportedEncodingException e) { Timber.e(e.getMessage()); return null; } } private void appendDeviceParams(@NonNull Uri.Builder builder) { builder.appendQueryParameter(Param.PHONE_NUMBER, phoneNumber); builder.appendQueryParameter(Param.ANDROID_ID, androidId); builder.appendQueryParameter(Param.IMEI, imei); builder.appendQueryParameter(Param.VERSION, BuildConfig.VERSION_NAME); builder.appendQueryParameter(Param.DEVICE_ID, deviceIdentifier); } interface Path { String SURVEYED_LOCALE = "surveyedlocale"; String NOTIFICATION = "processor"; String SURVEY_LIST_SERVICE = "surveymanager"; String SURVEY_HEADER_SERVICE = "surveymanager"; String DEVICE_NOTIFICATION = "devicenotification"; String TIME_CHECK = "devicetimerest"; } interface Param { String SURVEY_GROUP = "surveyGroupId"; String PHONE_NUMBER = "phoneNumber"; String IMEI = "imei"; String TIMESTAMP = "ts"; String LAST_UPDATED = "lastUpdateTime"; String HMAC = "h"; String VERSION = "ver"; String DEVICE_ID = "devId"; String ANDROID_ID = "androidId"; String PARAM_ACTION = "action"; String FORM_ID = "formID"; String SURVEY_ID = "surveyId"; String FILENAME = "fileName"; String VALUE_HEADER = "getSurveyHeader"; String VALUE_SURVEY = "getAvailableSurveysDevice"; String SEPARATOR = "&"; String EQUALS = "="; } }