package com.github.andlyticsproject.console.v2; import android.annotation.SuppressLint; import android.app.Activity; import android.util.Log; import com.github.andlyticsproject.console.AuthenticationException; import com.github.andlyticsproject.console.DevConsole; import com.github.andlyticsproject.console.DevConsoleException; import com.github.andlyticsproject.console.NetworkException; import com.github.andlyticsproject.model.AppInfo; import com.github.andlyticsproject.model.AppStats; import com.github.andlyticsproject.model.Comment; import com.github.andlyticsproject.model.DeveloperConsoleAccount; import com.github.andlyticsproject.model.RevenueSummary; import com.github.andlyticsproject.util.Utils; import org.apache.http.HttpStatus; import org.apache.http.client.CookieStore; import org.apache.http.client.HttpResponseException; import org.apache.http.client.ResponseHandler; import org.apache.http.client.methods.HttpPost; import org.apache.http.cookie.Cookie; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.DefaultHttpClient; import java.io.IOException; import java.util.ArrayList; import java.util.List; /** * This is a WIP class representing the new v2 version of the developer console. * The aim is to build it from scratch to make it a light weight and as well * documented at the end as possible. Once it is done and available to all * users, we will rip out the old code and replace it with this. * * Once v2 is available to all users, there is scope for better utilising the * available statistics data, so keep that in mind when developing this class. * For now though, keep it simple and get it working. * * See https://github.com/AndlyticsProject/andlytics/wiki/Developer-Console-v2 * for some more documentation * * This class fetches the data, which is then passed using {@link JsonParser} * */ @SuppressLint("DefaultLocale") public class DevConsoleV2 implements DevConsole { // 30 seconds -- for both socket and connection public static final int TIMEOUT = 30 * 1000; private static final String TAG = DevConsoleV2.class.getSimpleName(); private static final boolean DEBUG = false; private DefaultHttpClient httpClient; private DevConsoleAuthenticator authenticator; private String accountName; private DevConsoleV2Protocol protocol; private ResponseHandler<String> responseHandler = HttpClientFactory.createResponseHandler(); public static DevConsoleV2 createForAccount(String accountName, DefaultHttpClient httpClient) { DevConsoleAuthenticator authenticator = new OauthAccountManagerAuthenticator(accountName, httpClient); return new DevConsoleV2(httpClient, authenticator, new DevConsoleV2Protocol()); } public static DevConsoleV2 createForAccountAndPassword(String accountName, String password, DefaultHttpClient httpClient) { DevConsoleAuthenticator authenticator = new PasswordAuthenticator(accountName, password, httpClient); return new DevConsoleV2(httpClient, authenticator, new DevConsoleV2Protocol()); } private DevConsoleV2(DefaultHttpClient httpClient, DevConsoleAuthenticator authenticator, DevConsoleV2Protocol protocol) { this.httpClient = httpClient; this.authenticator = authenticator; this.accountName = authenticator.getAccountName(); this.protocol = protocol; } /** * Gets a list of available apps for the given account * * @param activity * @return * @throws DevConsoleException */ public synchronized List<AppInfo> getAppInfo(Activity activity) throws DevConsoleException { try { // the authenticator launched a sub-activity, bail out for now if (!authenticateWithCachedCredentialas(activity)) { return new ArrayList<AppInfo>(); } return fetchAppInfosAndStatistics(); } catch (AuthenticationException ex) { if (!authenticateFromScratch(activity)) { return new ArrayList<AppInfo>(); } return fetchAppInfosAndStatistics(); } } private List<AppInfo> fetchAppInfosAndStatistics() { // Fetch a list of available apps List<AppInfo> apps = fetchAppInfos(); for (AppInfo app : apps) { // Fetch remaining app statistics // Latest stats object, and active/total installs is fetched // in fetchAppInfos AppStats stats = app.getLatestStats(); fetchRatings(app, stats); RevenueSummary revenue = fetchRevenueSummary(app); app.setTotalRevenueSummary(revenue); // this is currently the same as the last item of the historical // data, so save some cycles and don't parse historical // XXX the definition of 'last day' is unclear: GMT? if (revenue != null) { stats.setTotalRevenue(revenue.getLastDay()); } } return apps; } /** * Gets a list of comments for the given app based on the startIndex and * count * * @param accountName * @param packageName * @param startIndex * @param count * @return * @throws DevConsoleException */ public synchronized List<Comment> getComments(Activity activity, String packageName, String developerId, int startIndex, int count, String displayLocale) throws DevConsoleException { try { if (!authenticateWithCachedCredentialas(activity)) { return new ArrayList<Comment>(); } return fetchComments(packageName, developerId, startIndex, count, displayLocale); } catch (AuthenticationException ex) { if (!authenticateFromScratch(activity)) { return new ArrayList<Comment>(); } return fetchComments(packageName, developerId, startIndex, count, displayLocale); } } public synchronized Comment replyToComment(Activity activity, String packageName, String developerId, String commentUniqueId, String reply) { try { if (!authenticateWithCachedCredentialas(activity)) { return null; } return replyToComment(packageName, developerId, commentUniqueId, reply); } catch (AuthenticationException ex) { if (!authenticateFromScratch(activity)) { return null; } return replyToComment(packageName, developerId, commentUniqueId, reply); } } private Comment replyToComment(String packageName, String developerId, String commentUiqueId, String reply) { String response = post(protocol.createCommentsUrl(developerId), protocol.createReplyToCommentRequest(packageName, commentUiqueId, reply), developerId); return protocol.parseCommentReplyResponse(response); } /** * Fetches a combined list of apps for all available console accounts * * @return combined list of apps * @throws DevConsoleException */ private List<AppInfo> fetchAppInfos() throws DevConsoleException { List<AppInfo> result = new ArrayList<AppInfo>(); for (DeveloperConsoleAccount consoleAccount : protocol.getSessionCredentials() .getDeveloperConsoleAccounts()) { String developerId = consoleAccount.getDeveloperId(); if (!consoleAccount.getCanAccessApps()) { Log.w(TAG, "Not allowed to fetch app info for " + developerId); continue; } Log.d(TAG, "Getting apps for " + developerId); String response = post(protocol.createFetchAppsUrl(developerId), protocol.createFetchAppInfosRequest(), developerId); // don't skip incomplete apps, so we can get the package list List<AppInfo> apps = protocol.parseAppInfosResponse(response, accountName, false); if (apps.isEmpty()) { continue; } for (AppInfo appInfo : apps) { appInfo.setDeveloperId(developerId); appInfo.setDeveloperName(consoleAccount.getName()); } result.addAll(apps); List<String> incompletePackages = new ArrayList<String>(); for (AppInfo app : apps) { if (app.isIncomplete()) { result.remove(app); incompletePackages.add(app.getPackageName()); } } Log.d(TAG, String.format("Found %d apps for %s", apps.size(), developerId)); Log.d(TAG, String.format("Incomplete packages: %d", incompletePackages.size())); if (incompletePackages.isEmpty()) { continue; } Log.d(TAG, String.format("Got %d incomplete apps, issuing details request", incompletePackages.size())); response = post(protocol.createFetchAppsUrl(developerId), protocol.createFetchAppInfosRequest(incompletePackages), developerId); // if info is not here, not much to do, skip List<AppInfo> extraApps = protocol.parseAppInfosResponse(response, accountName, true); Log.d(TAG, String.format("Got %d extra apps from details request", extraApps.size())); for (AppInfo appInfo : extraApps) { appInfo.setDeveloperId(developerId); appInfo.setDeveloperName(consoleAccount.getName()); } result.addAll(extraApps); } return result; } /** * Fetches statistics for the given packageName of the given statsType and * adds them to the given {@link AppStats} object * * This is not used as statistics can be fetched via fetchAppInfos Can use * it later to get historical etc data * * @param packageName * @param stats * @param statsType * @throws DevConsoleException */ @SuppressWarnings("unused") private void fetchStatistics(AppInfo appInfo, AppStats stats, int statsType) throws DevConsoleException { String developerId = appInfo.getDeveloperId(); String response = post(protocol.createFetchStatisticsUrl(developerId), protocol.createFetchStatisticsRequest(appInfo.getPackageName(), statsType), developerId); protocol.parseStatisticsResponse(response, stats, statsType); } /** * Fetches ratings for the given packageName and adds them to the given {@link AppStats} object * * @param packageName * The app to fetch ratings for * @param stats * The AppStats object to add them to * @throws DevConsoleException */ private void fetchRatings(AppInfo appInfo, AppStats stats) throws DevConsoleException { String developerId = appInfo.getDeveloperId(); String response = post(protocol.createCommentsUrl(developerId), protocol.createFetchRatingsRequest(appInfo.getPackageName()), developerId); protocol.parseRatingsResponse(response, stats); } private List<Comment> fetchComments(String packageName, String developerId, int startIndex, int count, String displayLocale) throws DevConsoleException { List<Comment> comments = new ArrayList<Comment>(); String response = post(protocol.createCommentsUrl(developerId), protocol.createFetchCommentsRequest(packageName, startIndex, count, displayLocale), developerId); comments.addAll(protocol.parseCommentsResponse(response)); return comments; } private RevenueSummary fetchRevenueSummary(AppInfo appInfo) throws DevConsoleException { try { String developerId = appInfo.getDeveloperId(); String response = post(protocol.createRevenueUrl(developerId), protocol.createFetchRevenueSummaryRequest(appInfo.getPackageName()), developerId); return protocol.parseRevenueResponse(response); } catch (NetworkException e) { // XXX not pretty, maybe use a dedicated exception? // if we don't have 'view financial info' permission for an app // getting revenue returns 403. // same sems to apply for 500 if (e.getStatusCode() == HttpStatus.SC_FORBIDDEN || e.getStatusCode() == HttpStatus.SC_INTERNAL_SERVER_ERROR) { return null; } throw e; } } private boolean authenticateWithCachedCredentialas(Activity activity) { return authenticate(activity, false); } private boolean authenticateFromScratch(Activity activity) { return authenticate(activity, true); } /** * Logs into the Android Developer Console * * @param reuseAuthentication * @throws DevConsoleException */ private boolean authenticate(Activity activity, boolean invalidateCredentials) throws DevConsoleException { if (invalidateCredentials) { protocol.invalidateSessionCredentials(); } if (protocol.hasSessionCredentials()) { // nothing to do return true; } SessionCredentials sessionCredentials = activity == null ? authenticator .authenticateSilently(invalidateCredentials) : authenticator.authenticate(activity, invalidateCredentials); protocol.setSessionCredentials(sessionCredentials); return protocol.hasSessionCredentials(); } private String post(String url, String postData, String developerId) { try { HttpPost post = new HttpPost(url); protocol.addHeaders(post, developerId); post.setEntity(new StringEntity(postData, "UTF-8")); if (DEBUG) { CookieStore cookieStore = httpClient.getCookieStore(); List<Cookie> cookies = cookieStore.getCookies(); for (Cookie c : cookies) { Log.d(TAG, String.format("****Cookie**** %s=%s", c.getName(), c.getValue())); } } return httpClient.execute(post, responseHandler); } catch (HttpResponseException e) { if (e.getStatusCode() == HttpStatus.SC_UNAUTHORIZED) { throw new AuthenticationException(e); } throw new NetworkException(e, e.getStatusCode()); } catch (IOException e) { throw new NetworkException(e); } } public boolean canReplyToComments() { return protocol.canReplyToComments(); } public boolean hasSessionCredentials() { return protocol.hasSessionCredentials(); } }