package com.github.andlyticsproject.console.v2; import android.annotation.SuppressLint; import com.github.andlyticsproject.console.DevConsoleProtocolException; import com.github.andlyticsproject.model.AppInfo; import com.github.andlyticsproject.model.AppStats; import com.github.andlyticsproject.model.Comment; import com.github.andlyticsproject.model.RevenueSummary; import com.github.andlyticsproject.util.FileUtils; import com.google.gson.JsonObject; import org.apache.http.client.methods.HttpPost; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.List; @SuppressLint("DefaultLocale") public class DevConsoleV2Protocol { // Base urls static final String URL_DEVELOPER_CONSOLE = "https://play.google.com/apps/publish"; static final String URL_APPS = DevConsoleV2Protocol.URL_DEVELOPER_CONSOLE + "/androidapps"; static final String URL_STATISTICS = DevConsoleV2Protocol.URL_DEVELOPER_CONSOLE + "/statistics"; static final String URL_REVIEWS = DevConsoleV2Protocol.URL_DEVELOPER_CONSOLE + "/reviews"; // Templates for payloads used in POST requests static final String FETCH_APPS_TEMPLATE = "{\"method\":\"fetch\"," + "\"params\":{\"2\":1,\"3\":7},\"xsrf\":\"%s\"}"; // 1$: comma separated list of package names static final String FETCH_APPS_BY_PACKAGES_TEMPLATE = "{\"method\":\"fetch\"," + "\"params\":{\"1\":[%1$s],\"3\":1},\"xsrf\":\"%2$s\"}"; // 1$: package name, 2$: XSRF static final String FETCH_APP_TEMPLATE = "{\"method\":\"fetch\"," + "\"params\":{\"1\":[\"%1$s\"],\"3\":0},\"xsrf\":\"%2$s\"}"; // 1$: package name, 2$: XSRF static final String GET_RATINGS_TEMPLATE = "{\"method\":\"getRatings\"," + "\"params\":{\"1\":[\"%1$s\"]},\"xsrf\":\"%2$s\"}"; // 1$: package name, 2$: start, 3$: num comments to fetch, 4$: display locale, 5$ XSRF static final String GET_REVIEWS_TEMPLATE = "{\"method\":\"getReviews\"," + "\"params\":{\"1\":\"%1$s\",\"2\":%2$d,\"3\":%3$d,\"8\":\"%4$s\",\"10\":0,\"18\":1},\"xsrf\":\"%5$s\"}"; // 1$: package name, 2$: stats type, 3$: stats by, 4$: XSRF static final String GET_COMBINED_STATS_TEMPLATE = "{\"method\":\"getCombinedStats\"," + "\"params\":{\"1\":\"%1$s\",\"2\":1,\"3\":%2$d,\"4\":[%3$d]},\"xsrf\":\"%4$s\"}"; // %1$s: package name, %2$s: XSRF static final String REVENUE_SUMMARY_TEMPLATE = "{\"method\":\"revenueSummary\",\"params\":{\"1\":\"%1$s\",\"2\":\"\"},\"xsrf\":\"%2$s\"}"; // %1$s: package name, %2$s: XSRF static final String REVENUE_HISTORICAL_DATA = "{\"method\":\"historicalData\",\"params\":{\"1\":\"%1$s\",\"2\":\"\"},\"xsrf\":\"%2$s\"}"; static final String REPLY_TO_COMMENTS_FEATURE = "REPLY_TO_COMMENTS"; // Represents the different ways to break down statistics by e.g. by android // version static final int STATS_BY_ANDROID_VERSION = 1; static final int STATS_BY_DEVICE = 2; static final int STATS_BY_COUNTRY = 3; static final int STATS_BY_LANGUAGE = 4; static final int STATS_BY_APP_VERSION = 5; static final int STATS_BY_CARRIER = 6; // Represents the different types of statistics e.g. active device installs static final int STATS_TYPE_ACTIVE_DEVICE_INSTALLS = 1; static final int STATS_TYPE_TOTAL_USER_INSTALLS = 8; static final int COMMENT_REPLY_MAX_LENGTH = 350; private SessionCredentials sessionCredentials; DevConsoleV2Protocol() { } DevConsoleV2Protocol(SessionCredentials sessionCredentials) { this.sessionCredentials = sessionCredentials; } SessionCredentials getSessionCredentials() { return sessionCredentials; } void setSessionCredentials(SessionCredentials sessionCredentials) { this.sessionCredentials = sessionCredentials; } boolean hasSessionCredentials() { return sessionCredentials != null; } void invalidateSessionCredentials() { sessionCredentials = null; } private void checkState() { if (sessionCredentials == null) { throw new IllegalStateException("Set session credentials first."); } } void addHeaders(HttpPost post, String developerId) { checkState(); post.addHeader("Host", "play.google.com"); post.addHeader("Connection", "keep-alive"); post.addHeader("Content-Type", "application/javascript; charset=UTF-8"); // XXX get this dynamically by fetching and executing the nocache.js file: // https://play.google.com/apps/publish/v2/gwt/com.google.wireless.android.vending.developer.fox.Fox.nocache.js post.addHeader("X-GWT-Permutation", "7E419416D8BA779A68D417481802D188"); post.addHeader("Origin", "https://play.google.com"); post.addHeader("X-GWT-Module-Base", "https://play.google.com/apps/publish/gwt/"); post.addHeader("Referer", "https://play.google.com/apps/publish/?dev_acc=" + developerId); } String createDeveloperUrl(String baseUrl, String developerId) { checkState(); return String.format("%s?dev_acc=%s", baseUrl, developerId); } String createFetchAppsUrl(String developerId) { return createDeveloperUrl(URL_APPS, developerId); } String createFetchStatisticsUrl(String developerId) { return createDeveloperUrl(URL_STATISTICS, developerId); } String createCommentsUrl(String developerId) { return createDeveloperUrl(URL_REVIEWS, developerId); } String createRevenueUrl(String developerId) { return createDeveloperUrl(URL_STATISTICS, developerId); } String createFetchAppInfosRequest() { checkState(); // TODO Check the remaining possible parameters to see if they are // needed for large numbers of apps return String.format(FETCH_APPS_TEMPLATE, sessionCredentials.getXsrfToken()); } String createFetchAppInfosRequest(List<String> packages) { checkState(); StringBuilder buff = new StringBuilder(); for (int i = 0; i < packages.size(); i++) { String packageName = packages.get(i); buff.append(packageName); if (i != packages.size() - 1) { buff.append(","); } } String packageList = buff.toString(); return String.format(FETCH_APPS_BY_PACKAGES_TEMPLATE, packageList, sessionCredentials.getXsrfToken()); } List<AppInfo> parseAppInfosResponse(String json, String accountName, boolean skipIncomplete) { try { return JsonParser.parseAppInfos(json, accountName, skipIncomplete); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } private static void saveDebugJson(String json) { FileUtils.tryWriteToDebugDir( String.format("console_reply_%d.json", System.currentTimeMillis()), json); } String createFetchAppInfoRequest(String packageName) { checkState(); return String.format(FETCH_APP_TEMPLATE, packageName, sessionCredentials.getXsrfToken()); } String createFetchStatisticsRequest(String packageName, int statsType) { checkState(); // Don't care about the breakdown at the moment: // STATS_BY_ANDROID_VERSION return String.format(GET_COMBINED_STATS_TEMPLATE, packageName, statsType, STATS_BY_ANDROID_VERSION, sessionCredentials.getXsrfToken()); } void parseStatisticsResponse(String json, AppStats stats, int statsType) { try { JsonParser.parseStatistics(json, stats, statsType); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } String createFetchRatingsRequest(String packageName) { checkState(); return String.format(GET_RATINGS_TEMPLATE, packageName, sessionCredentials.getXsrfToken()); } void parseRatingsResponse(String json, AppStats stats) { try { JsonParser.parseRatings(json, stats); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } String createFetchCommentsRequest(String packageName, int start, int pageSize, String displayLocale) { checkState(); return String.format(GET_REVIEWS_TEMPLATE, packageName, start, pageSize, displayLocale, sessionCredentials.getXsrfToken()); } String createReplyToCommentRequest(String packageName, String commentId, String reply) { checkState(); if (!canReplyToComments()) { throw new IllegalStateException( "Reply to comments feature not available for this account"); } // XXX we can probably do better, truncate for now if (reply.length() > COMMENT_REPLY_MAX_LENGTH) { reply = reply.substring(0, COMMENT_REPLY_MAX_LENGTH); } JsonObject replyObj = new JsonObject(); replyObj.addProperty("method", "sendReply"); JsonObject params = new JsonObject(); params.addProperty("1", packageName); params.addProperty("2", commentId); params.addProperty("3", reply); replyObj.add("params", params); replyObj.addProperty("xsrf", sessionCredentials.getXsrfToken()); return replyObj.toString(); } boolean hasFeature(String feature) { checkState(); return sessionCredentials.hasFeature(feature); } boolean canReplyToComments() { // this has apparently been removed because now everybody can // reply to comments // return hasFeature(REPLY_TO_COMMENTS_FEATURE); return true; } List<Comment> parseCommentsResponse(String json) { try { return JsonParser.parseComments(json); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } Comment parseCommentReplyResponse(String json) { try { return JsonParser.parseCommentReplyResponse(json); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } String createFetchRevenueSummaryRequest(String packageName) { checkState(); try { JSONObject jsonObj = new JSONObject(); jsonObj.put("method", "fetchStats"); jsonObj.put("xsrf", sessionCredentials.getXsrfToken()); JSONObject paramsObj = new JSONObject(); jsonObj.put("params", paramsObj); JSONArray paramOne = new JSONArray(); paramsObj.put("1", paramOne); JSONObject firstElem = new JSONObject(); firstElem.put("1", new JSONObject().put("1", packageName).put("2", "1")); firstElem.put("2", -1); firstElem.put("3", -1); JSONArray arr = new JSONArray(); arr.put(new JSONObject().put("1", 11).put("2", new JSONArray().put(sessionCredentials.getPreferredCurrency()))); arr.put(new JSONObject().put("1", 18).put("2", // summary, last day, last 7, last 30 new JSONArray().put("-1").put("1").put("7").put("30"))); firstElem.put("6", arr); firstElem.put("7", new JSONArray().put(18)); firstElem.put("8", new JSONArray().put(17)); paramOne.put(firstElem); return jsonObj.toString(); } catch (JSONException e) { throw new RuntimeException(e); } } RevenueSummary parseRevenueResponse(String json) { try { return JsonParser.parseRevenueResponse(json, sessionCredentials.getPreferredCurrency()); } catch (JSONException ex) { saveDebugJson(json); throw new DevConsoleProtocolException(json, ex); } } String createFetchHistoricalRevenueRequest(String packageName) { checkState(); return String.format(REVENUE_HISTORICAL_DATA, packageName, sessionCredentials.getXsrfToken()); } }