package com.chrome.codereview.requests; import android.accounts.Account; import android.accounts.AccountManager; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.net.http.AndroidHttpClient; import android.preference.PreferenceManager; import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import com.chrome.codereview.CodereviewApplication; import com.chrome.codereview.data.IssueStateProvider; import com.chrome.codereview.model.Comment; import com.chrome.codereview.model.Diff; import com.chrome.codereview.model.FileDiff; import com.chrome.codereview.model.Issue; import com.chrome.codereview.model.PatchSet; import com.chrome.codereview.model.PublishData; import com.google.android.gms.auth.GoogleAuthException; import com.google.android.gms.auth.GoogleAuthUtil; import com.google.android.gms.auth.UserRecoverableAuthException; import com.loopj.android.http.PersistentCookieStore; import org.apache.http.Header; import org.apache.http.HeaderElement; import org.apache.http.HttpEntity; import org.apache.http.HttpHeaders; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.NameValuePair; import org.apache.http.auth.AuthenticationException; import org.apache.http.client.CookieStore; import org.apache.http.client.entity.GzipDecompressingEntity; import org.apache.http.client.entity.UrlEncodedFormEntity; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.client.params.ClientPNames; import org.apache.http.client.protocol.ClientContext; import org.apache.http.cookie.Cookie; import org.apache.http.message.BasicNameValuePair; import org.apache.http.params.BasicHttpParams; import org.apache.http.params.HttpProtocolParams; import org.apache.http.protocol.BasicHttpContext; import org.apache.http.util.EntityUtils; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; /** * Created by sergeyv on 16/4/14. */ public class ServerCaller { private static class NotFoundException extends IOException { } public enum State { OK, NEEDS_ACCOUNT, NEEDS_AUTHORIZATION; } public static final Uri BASE_URL = Uri.parse("https://codereview.chromium.org"); public static final Uri SECONDARY_URL = Uri.parse("https://chromiumcodereview.appspot.com"); public static final String ACTION_UPDATE_ISSUE_MODIFICATION_TIME = "UPDATE_ISSUE_MODIFICATION_TIME"; public static final String EXTRA_ISSUE_ID = "ISSUE_ID"; public static final String EXTRA_MODIFICATION_TIME = "MODIFICATION_TIME"; private static final String AUTH_COOKIE_NAME = "SACSID"; private static final String XSRF_TOKEN_PREFERENCE = "ServerCaller_XSRF_TOKEN_PREFERENCE"; private static final String XSRF_TOKEN_TIME_PREFERENCE = "ServerCaller_XSRF_TOKEN_TIME_PREFERENCE"; private static final int TOKEN_LIFE_TIME = 25;//min private static final String CHROMIUM_EMAIL = "@chromium.org"; private static final String TOKEN_TYPE = "ah"; private static final Uri SEARCH_URL = BASE_URL.buildUpon().appendPath("search").appendQueryParameter("format", "json").build(); private static final Uri XSRF_URL = BASE_URL.buildUpon().appendPath("xsrf_token").build(); private static final Uri AUTH_COOKIE_URL = BASE_URL.buildUpon().appendEncodedPath("_ah/login").appendQueryParameter("continue", "nowhere").build(); private static final Uri ISSUE_API_URL = BASE_URL.buildUpon().appendPath("api").build(); private static final Uri INLINE_DRAFT = BASE_URL.buildUpon().appendPath("inline_draft").build(); private static final String PUBLISH = "publish"; private static final String ISSUE_PATH = "issue"; private static final Uri DOWNLOAD_DIFF = BASE_URL.buildUpon().appendPath("download").build(); private static final String COMMIT_PATH = "edit_flags"; private static final int N_THREADS = 3; private final AndroidHttpClient httpClient; private final BasicHttpContext httpContext; private final ExecutorService service; private Account chromiumAccount; private State state; private Context context; private HashSet<Integer> updatingIssues = new HashSet<Integer>(); private HashMap<Integer, Long> issueToModification = new HashMap<Integer, Long>(); public ServerCaller(Context context) { this.context = context; service = Executors.newFixedThreadPool(N_THREADS); httpClient = AndroidHttpClient.newInstance(""); httpContext = new BasicHttpContext(); httpContext.setAttribute(ClientContext.COOKIE_STORE, new PersistentCookieStore(context)); reset(); } public void reset() { initChromiumAccount(); if (chromiumAccount == null) { state = State.NEEDS_ACCOUNT; clearToken(); return; } if (hasValidAuthCookie()) { state = State.OK; } else { state = State.NEEDS_AUTHORIZATION; clearToken(); } } public static ServerCaller from(Context context) { return ((CodereviewApplication) context.getApplicationContext()).serverCaller(); } public ServerCaller.State getState() { return state; } public String getAccountName() { if (chromiumAccount == null) { return null; } return chromiumAccount.name; } public List<Issue> loadIssuesForUser(String accountName) { if (accountName == null) { return null; } Future<List<Issue>> futureMineIssues = service.submit(createSearchCallable(new SearchOptions.Builder().owner(accountName).closeState(SearchOptions.CloseState.OPEN).withMessages().create())); Future<List<Issue>> futureCcIssues = service.submit(createSearchCallable(new SearchOptions.Builder().cc(accountName).closeState(SearchOptions.CloseState.OPEN).withMessages().create())); Future<List<Issue>> futureOnReviewIssues = service.submit(createSearchCallable(new SearchOptions.Builder().reviewer(accountName).closeState(SearchOptions.CloseState.OPEN).withMessages().create())); try { List<Issue> mineIssues = futureMineIssues.get(); List<Issue> ccIssues = futureCcIssues.get(); List<Issue> onReviewIssues = futureOnReviewIssues.get(); mineIssues.addAll(ccIssues); mineIssues.addAll(onReviewIssues); return mineIssues; } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); } return new ArrayList<Issue>(); } public void tryToAuthenticate() throws UserRecoverableAuthException, GoogleAuthException, IOException, AuthenticationException { String token = GoogleAuthUtil.getToken(this.context, chromiumAccount.name, TOKEN_TYPE); loadCookie(token); loadAndSaveXSRFToken(); } private void initChromiumAccount() { AccountManager accountManager = AccountManager.get(context); Account[] accounts = accountManager.getAccountsByType(GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE); for (int i = 0; i < accounts.length; i++) { if (accounts[i].name.endsWith(CHROMIUM_EMAIL)) { chromiumAccount = accounts[i]; } } } public void publish(PublishData data) throws IOException, AuthenticationException, GoogleAuthException { Uri uri = BASE_URL.buildUpon().appendPath(data.issueId() + "").appendPath(PUBLISH).build(); ArrayList<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(); nameValuePairs.add(new BasicNameValuePair("xsrf_token", getXsrfToken())); nameValuePairs.addAll(data.toList()); executePost(uri, nameValuePairs); } public Diff loadDiff(int issueId, int patchSetId) throws IOException { HttpGet get = new HttpGet(DOWNLOAD_DIFF.buildUpon().appendPath(ISSUE_PATH + issueId + "_" + patchSetId + ".diff").build().toString()); String diff = executeRequest(get); return new Diff(patchSetId, diff); } public FileDiff loadDiff(int issueId, int patchSetId, int patchId) throws IOException { HttpGet get = new HttpGet(DOWNLOAD_DIFF.buildUpon().appendPath(ISSUE_PATH + issueId + "_" + patchSetId + "_" + patchId + ".diff").build().toString()); String diff = executeRequest(get); return FileDiff.from(diff); } private void loadAndSaveXSRFToken() throws IOException { HttpGet get = new HttpGet(XSRF_URL.toString()); get.addHeader("X-Requesting-XSRF-Token", ""); String xsrfToken = executeRequest(get); save(XSRF_TOKEN_PREFERENCE, xsrfToken); save(XSRF_TOKEN_TIME_PREFERENCE, System.currentTimeMillis()); } private String getXsrfToken() throws IOException, GoogleAuthException, AuthenticationException { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); long timeDiff = System.currentTimeMillis() - preferences.getLong(XSRF_TOKEN_TIME_PREFERENCE, 0); if (!preferences.contains(XSRF_TOKEN_PREFERENCE) || TimeUnit.MINUTES.toMillis(TOKEN_LIFE_TIME) < timeDiff) { tryToAuthenticate(); } return preferences.getString(XSRF_TOKEN_PREFERENCE, ""); } private boolean hasValidAuthCookie() { CookieStore cookieStore = (CookieStore) httpContext.getAttribute(ClientContext.COOKIE_STORE); for (Cookie cookie : cookieStore.getCookies()) { if (cookie.getName().equals(AUTH_COOKIE_NAME) && !cookie.isExpired(new Date())) { state = State.OK; return true; } } return false; } private void loadCookie(String authToken) throws AuthenticationException, IOException { String url = AUTH_COOKIE_URL.buildUpon().appendQueryParameter("auth", authToken).build().toString(); HttpGet method = new HttpGet(url); httpClient.getParams().setBooleanParameter(ClientPNames.HANDLE_REDIRECTS, false); HttpResponse res = httpClient.execute(method, httpContext); Header[] headers = res.getHeaders("Set-Cookie"); if (res.getEntity() != null) { res.getEntity().consumeContent(); } if (res.getStatusLine().getStatusCode() != HttpStatus.SC_MOVED_TEMPORARILY || headers.length == 0) { throw new AuthenticationException("Failed to get cookie"); } if (!hasValidAuthCookie()) throw new AuthenticationException("Failed to get cookie"); } public PatchSet loadPatchSet(int issueId, int patchSetId) { Uri uri = ISSUE_API_URL.buildUpon().appendPath(issueId + "").appendPath(patchSetId + "").appendQueryParameter("comments", "true").build(); try { return PatchSet.from(patchSetId, executeGetJSONRequest(uri)); } catch (Exception e) { e.printStackTrace(); } return null; } public Issue publishAndReloadIssue(final int issueId, PublishData publishData) throws GoogleAuthException, IOException, AuthenticationException { Uri uri = ISSUE_API_URL.buildUpon().appendPath(issueId + "").appendQueryParameter("messages", "true").build(); synchronized (updatingIssues) { updatingIssues.add(issueId); } if (publishData != null) { publish(publishData); } Issue issue = null; try { JSONObject jsonObject = executeGetJSONRequest(uri); JSONArray patchSetsJson = jsonObject.getJSONArray("patchsets"); List<Future<PatchSet>> futures = new ArrayList<Future<PatchSet>>(patchSetsJson.length()); for (int i = 0; i < patchSetsJson.length(); i++) { final int patchSetId = patchSetsJson.getInt(i); futures.add(service.submit(new Callable<PatchSet>() { @Override public PatchSet call() throws Exception { return loadPatchSet(issueId, patchSetId); } })); } List<PatchSet> patchSets = new ArrayList<PatchSet>(); for (Future<PatchSet> future : futures) { PatchSet patchSet = future.get(); if (patchSet != null) { patchSets.add(patchSet); } } issue = Issue.fromJSONObject(jsonObject, patchSets); Intent intent = new Intent(ACTION_UPDATE_ISSUE_MODIFICATION_TIME); intent.putExtra(EXTRA_ISSUE_ID, issueId); intent.putExtra(EXTRA_MODIFICATION_TIME, issue.lastModified().getTime()); LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } catch (Exception e) { e.printStackTrace(); } finally { synchronized (updatingIssues) { updatingIssues.remove(issueId); if (issueToModification.containsKey(issueId)) { long modificationTime = issueToModification.remove(issueId); long newModificationTime = issue != null ? issue.lastModified().getTime() : 0l; IssueStateProvider.updateIssueState(context, issue.id(), Math.max(modificationTime, newModificationTime)); } } } return issue; } private List<Issue> search(SearchOptions options) { Uri.Builder builder = SEARCH_URL.buildUpon(); options.fillParameters(builder); try { return Issue.fromJSONArray(executeGetJSONRequest(builder.build()).getJSONArray("results")); } catch (Exception e) { e.printStackTrace(); } return Collections.emptyList(); } public Callable<List<Issue>> createSearchCallable(final SearchOptions options) { return new Callable<List<Issue>>() { @Override public List<Issue> call() throws Exception { return search(options); } }; } public void inlineDraft(int issueId, int patchSetId, int patchId, Comment comment) throws IOException { ArrayList<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(); nameValuePairs.add(new BasicNameValuePair("side", comment.left() ? "a" : "b")); nameValuePairs.add(new BasicNameValuePair("snapshot", comment.left() ? "old" : "new")); nameValuePairs.add(new BasicNameValuePair("lineno", comment.line() + "")); nameValuePairs.add(new BasicNameValuePair("issue", issueId + "")); nameValuePairs.add(new BasicNameValuePair("patchset", patchSetId + "")); nameValuePairs.add(new BasicNameValuePair("patch", patchId + "")); nameValuePairs.add(new BasicNameValuePair("text", comment.text())); if (!TextUtils.isEmpty(comment.messageId())) { nameValuePairs.add(new BasicNameValuePair("message_id", comment.messageId())); } executePost(INLINE_DRAFT, nameValuePairs); } public void checkCQBit(int issueId, int patchSetId, boolean commit) throws GoogleAuthException, IOException, AuthenticationException { Uri uri = BASE_URL.buildUpon().appendPath(issueId + "").appendPath(COMMIT_PATH).build(); ArrayList<NameValuePair> nameValuePairs = new ArrayList<NameValuePair>(); nameValuePairs.add(new BasicNameValuePair("xsrf_token", getXsrfToken())); nameValuePairs.add(new BasicNameValuePair("commit", commit ? "1" : "0")); nameValuePairs.add(new BasicNameValuePair("last_patchset", patchSetId + "")); executePost(uri, nameValuePairs); } public boolean isClosedOrDeleted(int issueId) { Uri uri = ISSUE_API_URL.buildUpon().appendPath(issueId + "").build(); try { Issue issue = Issue.fromJSONObject(executeGetJSONRequest(uri)); return issue.isClosed(); } catch (NotFoundException e) { return true; } catch (Exception e) { e.printStackTrace(); } return false; } private void executePost(Uri uri, List<? extends NameValuePair> parameters) throws IOException { HttpPost post = new HttpPost(uri.toString()); UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(parameters); post.setEntity(formEntity); HttpResponse response = httpClient.execute(post, httpContext); HttpEntity entity = response.getEntity(); if (entity != null) { entity.consumeContent(); } } private String executeRequest(HttpUriRequest request) throws IOException { request.addHeader(HttpHeaders.ACCEPT_ENCODING, "gzip"); BasicHttpParams params = new BasicHttpParams(); HttpProtocolParams.setUserAgent(params, "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:6.0) Gecko/20100101 Firefox/6.0"); request.setParams(params); HttpResponse response = httpClient.execute(request, httpContext); if (response.getStatusLine().getStatusCode() == HttpStatus.SC_NOT_FOUND) { throw new NotFoundException(); } HttpEntity entity = response.getEntity(); Header contentEncodingHeader = entity.getContentEncoding(); if (contentEncodingHeader != null) { HeaderElement[] encodings = contentEncodingHeader.getElements(); for (int i = 0; i < encodings.length; i++) { if (encodings[i].getName().equalsIgnoreCase("gzip")) { entity = new GzipDecompressingEntity(entity); break; } } } String entityString = EntityUtils.toString(entity); return entityString; } private JSONObject executeGetJSONRequest(Uri uri) throws IOException, JSONException { return new JSONObject(executeRequest(new HttpGet(uri.toString()))); } private void save(String name, String value) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); preferences.edit().putString(name, value).apply(); } private void save(String name, long value) { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); preferences.edit().putLong(name, value).apply(); } private void clearToken() { SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); preferences.edit().remove(XSRF_TOKEN_PREFERENCE).apply(); } public void updateIssueState(Issue issue, long modificationTime) { synchronized (updatingIssues) { if (updatingIssues.contains(issue.id())) { issueToModification.put(issue.id(), modificationTime); return; } IssueStateProvider.updateIssueState(context, issue.id(), modificationTime); } } }