package com.github.andlyticsproject.console.v2; import android.util.Log; import com.github.andlyticsproject.console.DevConsoleException; import com.github.andlyticsproject.model.AppDetails; 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 org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.Locale; import java.util.TimeZone; /** * This class contains static methods used to parse JSON from {@link DevConsoleV2} * * See {@link https://github.com/AndlyticsProject/andlytics/wiki/Developer-Console-v2} for some more * documentation * */ public class JsonParser { private static final int REVENUE_LAST_30DAYS = 30; private static final int REVENUE_LAST_7DAYS = 7; private static final int REVENUE_LAST_DAY = 1; private static final int REVENUE_OVERALL = -1; private static final String TAG = JsonParser.class.getSimpleName(); private static final boolean DEBUG = false; private JsonParser() { } /** * Parses the supplied JSON string and adds the extracted ratings to the supplied * {@link AppStats} object * * @param json * @param stats * @throws JSONException */ static void parseRatings(String json, AppStats stats) throws JSONException { JSONObject data = new JSONObject(json).getJSONObject("result").getJSONArray("1") .getJSONObject(0); // Ratings values is in 8 JSONObject values = data.getJSONObject("8"); // Ratings are at index 1 - 5 stats.setRating(values.getInt("1"), values.getInt("2"), values.getInt("3"), values.getInt("4"), values.getInt("5")); // Comment count is in 7 stats.setNumberOfComments(data.getInt("7")); } /** * Parses the supplied JSON string and adds the extracted statistics to the supplied * {@link AppStats} object * based on the supplied statsType * Not used at the moment * * @param json * @param stats * @param statsType * @throws JSONException */ static void parseStatistics(String json, AppStats stats, int statsType) throws JSONException { // Extract the top level values array JSONObject values = new JSONObject(json).getJSONObject("result").getJSONObject("1"); /* * null * Nested array [null, [null, Array containing historical data]] * null * null * null * Nested arrays containing summary and historical data broken down by dimension e.g. * Android version * null * null * App name */ // For now we just care about todays value, later we may delve into the historical and // dimensioned data JSONArray historicalData = values.getJSONObject("1").getJSONArray("1"); JSONObject latestData = historicalData.getJSONObject(historicalData.length() - 1); /* * null * Date * [null, value] */ int latestValue = latestData.getJSONObject("2").getInt("1"); switch (statsType) { case DevConsoleV2Protocol.STATS_TYPE_TOTAL_USER_INSTALLS: stats.setTotalDownloads(latestValue); break; case DevConsoleV2Protocol.STATS_TYPE_ACTIVE_DEVICE_INSTALLS: stats.setActiveInstalls(latestValue); break; default: break; } } /** * Parses the supplied JSON string and builds a list of apps from it * * @param json * @param accountName * @param skipIncomplete * @return List of apps * @throws JSONException */ static List<AppInfo> parseAppInfos(String json, String accountName, boolean skipIncomplete) throws JSONException { Date now = new Date(); List<AppInfo> apps = new ArrayList<AppInfo>(); // Extract the base array containing apps JSONObject result = new JSONObject(json).getJSONObject("result"); if (DEBUG) { pp("result", result); } JSONArray jsonApps = result.optJSONArray("1"); if (DEBUG) { pp("jsonApps", jsonApps); } if (jsonApps == null) { // no apps yet? return apps; } int numberOfApps = jsonApps.length(); Log.d(TAG, String.format("Found %d apps in JSON", numberOfApps)); for (int i = 0; i < numberOfApps; i++) { AppInfo app = new AppInfo(); app.setAccount(accountName); app.setLastUpdate(now); /* * Per app key indexed objects: * "1" -> [ APP_INFO * * packageName * * publishState * * ? * ] * "3" -> [ APP_STATS * * Active installs * * Total ratings * * Average rating * * Errors * * Total installs * ] * "6" -> [ APP_DETAILS * * App name * * Low res icon * * High res icon * * Version * * Price * * ... * * Last update * ] */ JSONObject jsonApp = jsonApps.getJSONObject(i); /* * [ APP_INFO * * "1" -> packageName * * "7" -> publishState * * "11" -> ? * ] */ JSONObject jsonAppInfo = jsonApp.getJSONObject("1"); if (DEBUG) { pp("jsonAppInfo", jsonAppInfo); } String packageName = jsonAppInfo.getString("1"); // Look for "tmp.7238057230750432756094760456.235728507238057230542" if (packageName == null || (packageName.startsWith("tmp.") && Character.isDigit(packageName.charAt(4)))) { Log.d(TAG, String.format("Skipping draft app %d, package name=%s", i, packageName)); continue; // Draft app } // Check number code and last updated date // Published: 1 // Unpublished: 2 // Draft: 5 // Draft w/ in-app items?: 6 // TODO figure out the rest and add don't just skip, filter, etc. Cf. #223 int publishState = jsonAppInfo.optInt("7"); Log.d(TAG, String.format("%s: publishState=%d", packageName, publishState)); if (publishState != 1) { // Not a published app, skipping Log.d(TAG, String.format( "Skipping app %d with state != 1: package name=%s: state=%d", i, packageName, publishState)); continue; } app.setPublishState(publishState); app.setPackageName(packageName); /* App details * * App name * * Low res icon * * High res icon * * Version * * Price? * * ... * * Last update */ // skip if we can't get all the data // XXX should we just let this crash so we know there is a problem? if (!jsonApp.has("6")) { if (skipIncomplete) { Log.d(TAG, String.format( "Skipping app %d because no app details found: package name=%s", i, packageName)); } else { Log.d(TAG, "Adding incomplete app: " + packageName); apps.add(app); } continue; } JSONObject jsonAppDetails = jsonApp.getJSONObject("6"); if (DEBUG) { pp("jsonAppDetails", jsonAppDetails); } app.setName(jsonAppDetails.getString("1")); String description = ""; //appDetails.optString("3"); String changelog = ""; //appDetails.optString("5"); Long lastPlayStoreUpdate = jsonAppDetails.getLong("8"); AppDetails details = new AppDetails(description, changelog, lastPlayStoreUpdate); app.setDetails(details); app.setVersionName(jsonAppDetails.getString("4")); if (jsonAppDetails.has("3")) { app.setIconUrl(jsonAppDetails.getString("3")); } // App stats /* * Active installs * Total ratings * Average rating * Errors * Total installs */ if (!jsonApp.has("3")) { if (skipIncomplete) { Log.d(TAG, String.format( "Skipping app %d because no app stats found: package name=%s", i, packageName)); } else { Log.d(TAG, "Adding incomplete app: " + packageName); apps.add(app); } continue; } JSONObject jsonAppStats = jsonApp.getJSONObject("3"); if (DEBUG) { pp("jsonAppStats", jsonAppStats); } AppStats stats = new AppStats(); stats.setDate(now); if (jsonAppStats.length() < 4) { // no statistics (yet?) or weird format // TODO do we need differentiate? stats.setActiveInstalls(0); stats.setTotalDownloads(0); stats.setNumberOfErrors(0); } else { stats.setActiveInstalls(jsonAppStats.optInt("1", 0)); stats.setTotalDownloads(jsonAppStats.getInt("5")); stats.setNumberOfErrors(jsonAppStats.optInt("4")); } app.setLatestStats(stats); apps.add(app); } return apps; } private static void pp(String name, JSONArray jsonArr) { try { String pp = jsonArr == null ? "null" : jsonArr.toString(2); Log.d(TAG, String.format("%s: %s", name, pp)); FileUtils.writeToDebugDir(name + "-pp.json", pp); } catch (JSONException e) { Log.w(TAG, "Error printing JSON: " + e.getMessage(), e); } } private static void pp(String name, JSONObject jsonObj) { try { String pp = jsonObj == null ? "null" : jsonObj.toString(2); Log.d(TAG, String.format("%s: %s", name, pp)); FileUtils.writeToDebugDir(name + "-pp.json", pp); } catch (JSONException e) { Log.w(TAG, "Error printing JSON: " + e.getMessage(), e); } } /** * Parses the supplied JSON string and returns a list of comments. * * @param json * @return * @throws JSONException */ static List<Comment> parseComments(String json) throws JSONException { List<Comment> comments = new ArrayList<Comment>(); /* * null * Array containing arrays of comments * numberOfComments */ JSONArray jsonComments = new JSONObject(json).getJSONObject("result").getJSONArray("1"); int count = jsonComments.length(); for (int i = 0; i < count; i++) { Comment comment = new Comment(); JSONObject jsonComment = jsonComments.getJSONObject(i); // TODO These examples are out of date and need updating /* * null * "gaia:17919762185957048423:1:vm:11887109942373535891", -- ID? * "REVIEWERS_NAME", * "1343652956570", -- DATE? * RATING, * null * "COMMENT", * null, * "VERSION_NAME", * [ null, * "DEVICE_CODE_NAME", * "DEVICE_MANFACTURER", * "DEVICE_MODEL" * ], * "LOCALE", * null, * 0 */ // Example with developer reply /* * [ * null, * "gaia:12824185113034449316:1:vm:18363775304595766012", * "Micka�l", * "1350333837326", * 1, * "", * "Nul\tNul!! N'arrive pas a scanner le moindre code barre!", * 73, * "3.2.5", * [ * null, * "X10i", * "SEMC", * "Xperia X10" * ], * "fr_FR", * [ * null, * "Prixing fonctionne pourtant bien sur Xperia X10. Essayez de prendre un minimum de recul, au moins 20 � 30cm, �vitez les ombres et les reflets. N'h�sitez pas � nous �crire sur contact@prixing.fr pour une assistance personnalis�e." * , * null, * "1350393460968" * ], * 1 * ] */ String uniqueId = jsonComment.getString("1"); comment.setUniqueId(uniqueId); String user = jsonComment.optString("2"); if (user != null && !"".equals(user) && !"null".equals(user)) { comment.setUser(user); } comment.setDate(parseDate(jsonComment.getLong("3"))); comment.setRating(jsonComment.getInt("4")); String version = jsonComment.optString("7"); if (version != null && !"".equals(version) && !version.equals("null")) { comment.setAppVersion(version); } JSONObject jsonCommentReview = jsonComment.optJSONObject("5"); String commentLang = jsonCommentReview.getString("1"); String commentText = jsonCommentReview.getString("3"); String commentTitle = jsonCommentReview.getString("2"); if (commentTitle.length() == 0) { // Title field is empty, see if the title is part of the comment text String originalTitleAndComment[] = commentText.split("\\t"); if (originalTitleAndComment.length == 2) { commentTitle = originalTitleAndComment[0]; commentText = originalTitleAndComment[1]; } } comment.setLanguage(commentLang); comment.setOriginalText(commentText); // overwritten if translation is available comment.setText(commentText); comment.setOriginalTitle(commentTitle); comment.setTitle(commentTitle); JSONObject translation = jsonComment.optJSONObject("11"); if (translation != null) { String displayLanguage = Locale.getDefault().getLanguage(); String translationLang = translation.getString("1"); if (translation.has("2")) { String translationTitle = translation.getString("2"); if (translationLang.contains(displayLanguage)) { comment.setTitle(translationTitle); } } // Apparently, a translation body is not always provided // Possibly happens if the translation fails or equals the original if (translation.has("3")) { String translationText = translation.getString("3"); if (translationLang.contains(displayLanguage)) { comment.setText(translationText); } } } JSONObject jsonDevice = jsonComment.optJSONObject("16"); if (jsonDevice != null) { String device = jsonDevice.optString("3"); JSONArray extraInfo = jsonDevice.optJSONArray("2"); if (extraInfo != null) { device += " " + extraInfo.optString(0); } comment.setDevice(device.trim()); } JSONObject jsonReply = jsonComment.optJSONObject("9"); if (jsonReply != null) { Comment reply = new Comment(true); reply.setText(jsonReply.getString("1")); reply.setDate(parseDate(jsonReply.getLong("3"))); reply.setOriginalCommentDate(comment.getDate()); comment.setReply(reply); } comments.add(comment); } return comments; } static Comment parseCommentReplyResponse(String json) throws JSONException { // {"result":{"1":{"1":"REPLY","3":"TIME_STAMP"},"2":true},"xsrf":"XSRF_TOKEN"} // or // {"error":{"data":{"1":ERROR_CODE},"code":ERROR_CODE}} JSONObject jsonObj = new JSONObject(json); if (jsonObj.has("error")) { throw parseError(jsonObj, "replying to comments"); } JSONObject replyObj = jsonObj.getJSONObject("result").getJSONObject("1"); Comment result = new Comment(true); result.setText(replyObj.getString("1")); result.setDate(parseDate(Long.parseLong(replyObj.getString("3")))); return result; } private static DevConsoleException parseError(JSONObject jsonObj, String message) throws JSONException { JSONObject errorObj = jsonObj.getJSONObject("error"); String data = errorObj.getJSONObject("data").optString("1"); String errorCode = errorObj.optString("code"); return new DevConsoleException(String.format("Error %s: %s, errorCode=%s", message, data, errorCode)); } static RevenueSummary parseRevenueResponse(String json, String currency) throws JSONException { JSONObject jsonObj = new JSONObject(json); if (jsonObj.has("error")) { throw parseError(jsonObj, "fetch revenue summary"); } JSONArray arr = jsonObj.getJSONObject("result").getJSONArray("1"); if (arr.length() == 0) { return null; } JSONObject summaryObj = arr.getJSONObject(0); // free app or no revenue available if (!summaryObj.has("1")) { return null; } JSONArray revenueArr = summaryObj.getJSONArray("1"); double overall = 0; double lastDay = 0; double last30Days = 0; double last7Days = 0; for (int i = 0; i < revenueArr.length(); i++) { JSONObject revenueObj = revenueArr.getJSONObject(i); int period = revenueObj.getJSONArray("1").getInt(0); double value = revenueObj.getJSONArray("2").optJSONObject(0).optDouble("1", 0.0) / 1000000; switch (period) { case REVENUE_OVERALL: overall = value; break; case REVENUE_LAST_DAY: lastDay = value; break; case REVENUE_LAST_7DAYS: last7Days = value; break; case REVENUE_LAST_30DAYS: last30Days = value; break; default: throw new IllegalArgumentException("Unknown revenue period: " + period); } } long timeInMillis = summaryObj.getLong("2"); Calendar cal = Calendar.getInstance(); /* // TODO Work out timezone String tzStr = summaryObj.optString("3"); TimeZone tz = TimeZone.getTimeZone(tzStr); cal = Calendar.getInstance(tz); */ cal.setTimeInMillis(timeInMillis); return RevenueSummary.createTotal(currency, cal.getTime(), lastDay, last7Days, last30Days, overall); } /** * Parses the given date * * @param unixDateCode * @return */ private static Date parseDate(long unixDateCode) { return new Date(unixDateCode); } }