diff --git a/api/src/com/todoroo/andlib/service/HttpRestClient.java b/api/src/com/todoroo/andlib/service/HttpRestClient.java
index c0c24df52..48b7385cf 100644
--- a/api/src/com/todoroo/andlib/service/HttpRestClient.java
+++ b/api/src/com/todoroo/andlib/service/HttpRestClient.java
@@ -115,23 +115,27 @@ public class HttpRestClient implements RestClient {
int statusCode = response.getStatusLine().getStatusCode();
if(statusCode >= HTTP_UNAVAILABLE_START && statusCode <= HTTP_UNAVAILABLE_END) {
throw new HttpUnavailableException();
- } else if(statusCode != HTTP_OK) {
- throw new HttpErrorException(response.getStatusLine().getStatusCode(),
- response.getStatusLine().getReasonPhrase());
}
HttpEntity entity = response.getEntity();
+ String body = null;
if (entity != null) {
InputStream contentStream = entity.getContent();
try {
- return convertStreamToString(contentStream);
+ body = convertStreamToString(contentStream);
} finally {
contentStream.close();
}
}
- return null;
+ if(statusCode != HTTP_OK) {
+ System.out.println(body);
+ throw new HttpErrorException(response.getStatusLine().getStatusCode(),
+ response.getStatusLine().getReasonPhrase());
+ }
+
+ return body;
}
/**
diff --git a/astrid/res/layout/web_service_text_row.xml b/astrid/res/layout/web_service_text_row.xml
index 7ef479b7f..481243e8e 100644
--- a/astrid/res/layout/web_service_text_row.xml
+++ b/astrid/res/layout/web_service_text_row.xml
@@ -2,22 +2,18 @@
+ android:layout_height="wrap_content"
+ android:background="#9ee5ff">
@@ -25,10 +21,11 @@
@@ -41,6 +38,6 @@
android:layout_marginTop="15dip"
android:paddingLeft="7dip"
android:paddingRight="7dip"
- android:gravity="center" />
+ android:scaleType="center" />
diff --git a/astrid/src/com/todoroo/astrid/activity/AdTestActivity.java b/astrid/src/com/todoroo/astrid/activity/AdTestActivity.java
index ad747a945..0a1318062 100644
--- a/astrid/src/com/todoroo/astrid/activity/AdTestActivity.java
+++ b/astrid/src/com/todoroo/astrid/activity/AdTestActivity.java
@@ -4,7 +4,6 @@ import android.app.Activity;
import android.os.Bundle;
import android.view.ViewGroup.LayoutParams;
import android.widget.FrameLayout;
-import android.widget.ScrollView;
import com.todoroo.astrid.data.Task;
import com.todoroo.astrid.service.AstridDependencyInjector;
@@ -26,10 +25,8 @@ public class AdTestActivity extends Activity {
webServicesView.setLayoutParams(new FrameLayout.LayoutParams(
LayoutParams.FILL_PARENT, LayoutParams.WRAP_CONTENT));
webServicesView.setPadding(10, 10, 10, 10);
- ScrollView scrollView = new ScrollView(this);
- scrollView.addView(webServicesView);
- setContentView(scrollView);
+ setContentView(webServicesView);
Task task = new Task();
task.setValue(Task.TITLE, "America (The Book)"); //$NON-NLS-1$
diff --git a/astrid/src/com/todoroo/astrid/helper/AmazonRequestsHelper.java b/astrid/src/com/todoroo/astrid/helper/AmazonRequestsHelper.java
new file mode 100644
index 000000000..5fdb3aeeb
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/helper/AmazonRequestsHelper.java
@@ -0,0 +1,294 @@
+/**********************************************************************************************
+ * Copyright 2009 Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file
+ * except in compliance with the License. A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0/
+ *
+ * or in the "LICENSE.txt" file accompanying this file. This file is distributed on an "AS IS"
+ * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+ * License for the specific language governing permissions and limitations under the License.
+ *
+ * ********************************************************************************************
+ *
+ * Amazon Product Advertising API
+ * Signed Requests Sample Code
+ *
+ * API Version: 2009-03-31
+ *
+ */
+
+package com.todoroo.astrid.helper;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Calendar;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.SortedMap;
+import java.util.TimeZone;
+import java.util.TreeMap;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import org.apache.commons.codec.binary.Base64;
+
+/**
+ * This class contains all the logic for signing requests
+ * to the Amazon Product Advertising API.
+ */
+@SuppressWarnings("nls")
+public class AmazonRequestsHelper {
+ /**
+ * All strings are handled as UTF-8
+ */
+ private static final String UTF8_CHARSET = "UTF-8";
+
+ /**
+ * The HMAC algorithm required by Amazon
+ */
+ private static final String HMAC_SHA256_ALGORITHM = "HmacSHA256";
+
+ /**
+ * This is the URI for the service, don't change unless you really know
+ * what you're doing.
+ */
+ private static final String REQUEST_URI = "/onca/xml";
+
+ /**
+ * The sample uses HTTP GET to fetch the response. If you changed the sample
+ * to use HTTP POST instead, change the value below to POST.
+ */
+ private static final String REQUEST_METHOD = "GET";
+
+ private String endpoint = null;
+ private String awsAccessKeyId = null;
+ private String awsSecretKey = null;
+
+ private SecretKeySpec secretKeySpec = null;
+ private Mac mac = null;
+
+ /**
+ * You must provide the three values below to initialize the helper.
+ *
+ * @param endpoint Destination for the requests.
+ * @param awsAccessKeyId Your AWS Access Key ID
+ * @param awsSecretKey Your AWS Secret Key
+ */
+ public static AmazonRequestsHelper getInstance(
+ String endpoint,
+ String awsAccessKeyId,
+ String awsSecretKey
+ ) throws IllegalArgumentException, UnsupportedEncodingException, NoSuchAlgorithmException, InvalidKeyException
+ {
+ if (null == endpoint || endpoint.length() == 0)
+ { throw new IllegalArgumentException("endpoint is null or empty"); }
+ if (null == awsAccessKeyId || awsAccessKeyId.length() == 0)
+ { throw new IllegalArgumentException("awsAccessKeyId is null or empty"); }
+ if (null == awsSecretKey || awsSecretKey.length() == 0)
+ { throw new IllegalArgumentException("awsSecretKey is null or empty"); }
+
+ AmazonRequestsHelper instance = new AmazonRequestsHelper();
+ instance.endpoint = endpoint.toLowerCase();
+ instance.awsAccessKeyId = awsAccessKeyId;
+ instance.awsSecretKey = awsSecretKey;
+
+ byte[] secretyKeyBytes = instance.awsSecretKey.getBytes(UTF8_CHARSET);
+ instance.secretKeySpec = new SecretKeySpec(secretyKeyBytes, HMAC_SHA256_ALGORITHM);
+ instance.mac = Mac.getInstance(HMAC_SHA256_ALGORITHM);
+ instance.mac.init(instance.secretKeySpec);
+
+ return instance;
+ }
+
+ /**
+ * The construct is private since we'd rather use getInstance()
+ */
+ private AmazonRequestsHelper() {
+ //
+ }
+
+ /**
+ * This method signs requests in hashmap form. It returns a URL that should
+ * be used to fetch the response. The URL returned should not be modified in
+ * any way, doing so will invalidate the signature and Amazon will reject
+ * the request.
+ */
+ public String sign(Map params) {
+ // Let's add the AWSAccessKeyId and Timestamp parameters to the request.
+ params.put("AWSAccessKeyId", this.awsAccessKeyId);
+ params.put("Timestamp", this.timestamp());
+
+ // The parameters need to be processed in lexicographical order, so we'll
+ // use a TreeMap implementation for that.
+ SortedMap sortedParamMap = new TreeMap(params);
+
+ // get the canonical form the query string
+ String canonicalQS = this.canonicalize(sortedParamMap);
+
+ // create the string upon which the signature is calculated
+ String toSign =
+ REQUEST_METHOD + "\n"
+ + this.endpoint + "\n"
+ + REQUEST_URI + "\n"
+ + canonicalQS;
+
+ // get the signature
+ String hmac = this.hmac(toSign);
+ String sig = this.percentEncodeRfc3986(hmac);
+
+ // construct the URL
+ String url =
+ "http://" + this.endpoint + REQUEST_URI + "?" + canonicalQS + "&Signature=" + sig;
+
+ return url;
+ }
+
+ /**
+ * This method signs requests in query-string form. It returns a URL that
+ * should be used to fetch the response. The URL returned should not be
+ * modified in any way, doing so will invalidate the signature and Amazon
+ * will reject the request.
+ */
+ public String sign(String queryString) {
+ // let's break the query string into it's constituent name-value pairs
+ Map params = this.createParameterMap(queryString);
+
+ // then we can sign the request as before
+ return this.sign(params);
+ }
+
+ /**
+ * Compute the HMAC.
+ *
+ * @param stringToSign String to compute the HMAC over.
+ * @return base64-encoded hmac value.
+ */
+ private String hmac(String stringToSign) {
+ String signature = null;
+ byte[] data;
+ byte[] rawHmac;
+ try {
+ data = stringToSign.getBytes(UTF8_CHARSET);
+ rawHmac = mac.doFinal(data);
+ Base64 encoder = new Base64();
+ signature = new String(encoder.encode(rawHmac));
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(UTF8_CHARSET + " is unsupported!", e);
+ }
+ return signature;
+ }
+
+ /**
+ * Generate a ISO-8601 format timestamp as required by Amazon.
+ *
+ * @return ISO-8601 format timestamp.
+ */
+ private String timestamp() {
+ String timestamp = null;
+ Calendar cal = Calendar.getInstance();
+ DateFormat dfm = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
+ dfm.setTimeZone(TimeZone.getTimeZone("GMT"));
+ timestamp = dfm.format(cal.getTime());
+ return timestamp;
+ }
+
+ /**
+ * Canonicalize the query string as required by Amazon.
+ *
+ * @param sortedParamMap Parameter name-value pairs in lexicographical order.
+ * @return Canonical form of query string.
+ */
+ private String canonicalize(SortedMap sortedParamMap) {
+ if (sortedParamMap.isEmpty()) {
+ return "";
+ }
+
+ StringBuffer buffer = new StringBuffer();
+ Iterator> iter = sortedParamMap.entrySet().iterator();
+
+ while (iter.hasNext()) {
+ Map.Entry kvpair = iter.next();
+ buffer.append(percentEncodeRfc3986(kvpair.getKey()));
+ buffer.append("=");
+ buffer.append(percentEncodeRfc3986(kvpair.getValue()));
+ if (iter.hasNext()) {
+ buffer.append("&");
+ }
+ }
+ String cannoical = buffer.toString();
+ return cannoical;
+ }
+
+ /**
+ * Percent-encode values according the RFC 3986. The built-in Java
+ * URLEncoder does not encode according to the RFC, so we make the
+ * extra replacements.
+ *
+ * @param s decoded string
+ * @return encoded string per RFC 3986
+ */
+ private String percentEncodeRfc3986(String s) {
+ String out;
+ try {
+ out = URLEncoder.encode(s, UTF8_CHARSET)
+ .replace("+", "%20")
+ .replace("*", "%2A")
+ .replace("%7E", "~");
+ } catch (UnsupportedEncodingException e) {
+ out = s;
+ }
+ return out;
+ }
+
+ /**
+ * Takes a query string, separates the constituent name-value pairs
+ * and stores them in a hashmap.
+ *
+ * @param queryString
+ * @return
+ */
+ private Map createParameterMap(String queryString) {
+ Map map = new HashMap();
+ String[] pairs = queryString.split("&");
+
+ for (String pair: pairs) {
+ if (pair.length() < 1) {
+ continue;
+ }
+
+ String[] tokens = pair.split("=",2);
+ for(int j=0; j params = new HashMap();
+ params.put("Service", "AWSECommerceService");
+ params.put("Version", "2011-08-01");
+ params.put("Operation", "ItemSearch");
+ params.put("Availability", "Available");
+ params.put("ResponseGroup", "Images");
+ params.put("Keywords",
+ URLEncoder.encode(task.getValue(Task.TITLE), "UTF-8"));
+ params.put("SearchIndex", "All");
+ params.put("AssociateTag", ASSOCIATE_TAG);
+
+ String requestUrl = helper.sign(params);
+ String result = restClient.get(requestUrl);
+
+ activity.runOnUiThread(new AmazonSearchResultsProcessor(body,
+ result));
+
+ } catch (Exception e) {
+ displayError(e, body);
+ }
+ }
+ }.start();
+ }
+
+ private class AmazonSearchResultsProcessor implements Runnable {
+
+ private final LinearLayout body;
+ private final String searchResults;
+ private final MarginLayoutParams params;
+
+ public AmazonSearchResultsProcessor(LinearLayout body,
+ String searchResults) {
+ this.body = body;
+ this.searchResults = searchResults;
+
+ params = new LinearLayout.LayoutParams(
+ LayoutParams.WRAP_CONTENT, LayoutParams.FILL_PARENT);
+ params.rightMargin = Math.round(15 * metrics.density);
+ }
+
+ @Override
+ public void run() {
+ try {
+ body.removeAllViews();
+
+ XmlPullParserFactory factory = XmlPullParserFactory.newInstance();
+ factory.setNamespaceAware(true);
+
+ XmlPullParser xpp = factory.newPullParser();
+
+ xpp.setInput(new StringReader(searchResults));
+ int eventType = xpp.getEventType();
+
+ String asin = null, image = null, totalResults = null;
+ while (eventType != XmlPullParser.END_DOCUMENT) {
+ if(eventType == XmlPullParser.START_TAG) {
+ if("ASIN".equals(xpp.getName()))
+ asin = xpp.nextText();
+ else if("MediumImage".equals(xpp.getName())) {
+ xpp.next();
+ image = xpp.nextText();
+ } else if("Error".equals(xpp.getName())) {
+ while(!"Message".equals(xpp.getName()))
+ xpp.next();
+ throw new HttpErrorException(0, xpp.getText());
+ } else if("TotalResults".equals(xpp.getText()))
+ totalResults = xpp.nextText();
+ } else if(eventType == XmlPullParser.END_TAG) {
+ if("Item".equals(xpp.getName()))
+ renderItem(asin, image);
+ }
+ eventType = xpp.next();
+ }
+
+ if(totalResults != null) {
+ String moreLabel = String.format("Show all %s results",
+ totalResults);
+ String url = String.format("http://www.amazon.com/s/?field-keywords=%s&tag=%s",
+ URLEncoder.encode(task.getValue(Task.TITLE), "UTF-8"), ASSOCIATE_TAG);
+
+ View view = inflateTextRow(body, moreLabel, "", url);
+ view.setLayoutParams(params);
+ view.setBackgroundColor(Color.rgb(200, 200, 200));
+ }
+
+ } catch (Exception e) {
+ displayError(e, body);
+ }
+ }
+
+ private void renderItem(String asin, String image) {
+ AsyncImageView imageView = new AsyncImageView(activity);
+ imageView.setDefaultImageResource(R.drawable.ic_contact_picture_2);
+ imageView.setUrl(image);
+ imageView.setLayoutParams(params);
+ imageView.setScaleType(ScaleType.FIT_CENTER);
+ imageView.setTag(String.format("http://www.amazon.com/dp/%s/?tag=%s", asin, ASSOCIATE_TAG));
+ imageView.setOnClickListener(linkClickListener);
+
+ body.addView(imageView);
}
}
+
/**
* Initialize Google search results
*/
@@ -99,88 +220,106 @@ public class WebServicesView extends LinearLayout {
final LinearLayout body = addHorizontalScroller();
- ProgressBar progressBar = new ProgressBar(getContext());
- progressBar.setIndeterminate(true);
- progressBar.setLayoutParams(new LinearLayout.LayoutParams(LayoutParams.FILL_PARENT,
- LayoutParams.FILL_PARENT));
- body.addView(progressBar);
-
new Thread() {
@Override
public void run() {
- Exception exception = null;
- JSONObject searchResults = null;
-
try {
String url = GOOGLE_SEARCH_URL +
URLEncoder.encode(task.getValue(Task.TITLE), "UTF-8");
String result = restClient.get(url);
- searchResults = new JSONObject(result);
- } catch (UnsupportedEncodingException e) {
- exception = e;
- } catch (IOException e) {
- exception = e;
- } catch (JSONException e) {
- exception = e;
- }
+ final JSONObject searchResults = new JSONObject(result);
- final Exception finalException = exception;
- final JSONObject finalResults = searchResults;
- activity.runOnUiThread(new Runnable() {
- public void run() {
- body.removeAllViews();
-
- if(finalException != null)
- displayError(finalException, body);
- else {
- try {
- processGoogleSearchResults(body,
- finalResults.getJSONObject("responseData"));
- } catch (JSONException e) {
- displayError(e, body);
- }
- }
- }
- });
+ activity.runOnUiThread(new GoogleSearchResultsProcessor(body,
+ searchResults.getJSONObject("responseData")));
+
+ } catch (Exception e) {
+ displayError(e, body);
+ }
}
}.start();
}
- protected void processGoogleSearchResults(LinearLayout body,
- JSONObject searchResults) throws JSONException {
+ private class GoogleSearchResultsProcessor implements Runnable {
- JSONArray results = searchResults.getJSONArray("results");
+ private final LinearLayout body;
+ private final JSONObject searchResults;
- for(int i = 0; i < results.length(); i++) {
- JSONObject result = results.getJSONObject(i);
- View view = inflater.inflate(R.layout.web_service_text_row, body, false);
- ((TextView)view.findViewById(R.id.title)).setText(result.getString("titleNoFormatting"));
- ((TextView)view.findViewById(R.id.url)).setText(result.getString("visibleUrl"));
- body.addView(view);
-
- String url = result.getString("url");
- view.setTag(url);
+ public GoogleSearchResultsProcessor(LinearLayout body,
+ JSONObject searchResults) {
+ this.body = body;
+ this.searchResults = searchResults;
}
- JSONObject cursor = searchResults.getJSONObject("cursor");
- String moreLabel = String.format("Show all %s results",
- cursor.getString("estimatedResultCount"));
- String url = cursor.getString("moreResultsUrl");
+ public void run() {
+ body.removeAllViews();
+
+ try {
+ JSONArray results = searchResults.getJSONArray("results");
+ LayoutParams params = new LinearLayout.LayoutParams(
+ Math.round(metrics.widthPixels * 0.9f),
+ Math.round(ROW_HEIGHT * metrics.density));
+ params.setMargins(10, 0, 10, 0);
+
+ for(int i = 0; i < results.length(); i++) {
+ JSONObject result = results.getJSONObject(i);
+ String title = StringEscapeUtils.unescapeHtml(result.getString("titleNoFormatting"));
+ View view = inflateTextRow(body, title,
+ result.getString("visibleUrl"), result.getString("url"));
+ view.setLayoutParams(params);
+ }
+
+ JSONObject cursor = searchResults.getJSONObject("cursor");
+ String moreLabel = String.format("Show all %s results",
+ cursor.getString("estimatedResultCount"));
+ String url = cursor.getString("moreResultsUrl");
+
+ View view = inflateTextRow(body, moreLabel, "", url);
+ view.setLayoutParams(params);
+ view.setBackgroundColor(Color.rgb(200, 200, 200));
+ } catch (JSONException e) {
+ displayError(e, body);
+ }
+ }
+ }
+
+ protected View inflateTextRow(ViewGroup body, String title, String subtitle,
+ String tag) {
View view = inflater.inflate(R.layout.web_service_text_row, body, false);
- ((TextView)view.findViewById(R.id.title)).setText(moreLabel);
- view.setBackgroundColor(Color.rgb(200, 200, 200));
- view.setTag(url);
+ ((TextView)view.findViewById(R.id.title)).setText(title);
+ ((TextView)view.findViewById(R.id.url)).setText(subtitle);
+ view.setOnClickListener(linkClickListener);
+ view.setTag(tag);
body.addView(view);
+ return view;
}
- protected void displayError(Exception exception, LinearLayout body) {
+ public OnClickListener linkClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if(v.getTag() instanceof String) {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setData(Uri.parse((String) v.getTag()));
+ activity.startActivity(intent);
+ }
+ }
+ };
+
+ protected void displayError(final Exception exception, final LinearLayout body) {
exceptionService.reportError("google-error", exception);
- TextView textView = new TextView(getContext());
- textView.setTextAppearance(getContext(), R.style.TextAppearance_Medium);
- textView.setText(exception.toString());
- body.addView(textView);
+ activity.runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ body.removeAllViews();
+
+ TextView textView = new TextView(getContext());
+ textView.setTextAppearance(getContext(), R.style.TextAppearance_Medium);
+ textView.setText(exception.getClass().getSimpleName() + ": " +
+ exception.getLocalizedMessage());
+ body.addView(textView);
+ }
+ });
}
protected LinearLayout addHorizontalScroller() {
@@ -191,16 +330,24 @@ public class WebServicesView extends LinearLayout {
LinearLayout body = new LinearLayout(getContext());
body.setLayoutParams(new FrameLayout.LayoutParams(LayoutParams.FILL_PARENT,
- Math.round(100 * metrics.density)));
+ Math.round(ROW_HEIGHT * metrics.density)));
scroll.addView(body);
+ ProgressBar progressBar = new ProgressBar(getContext());
+ progressBar.setIndeterminate(true);
+ LayoutParams layoutParams = new LinearLayout.LayoutParams(LayoutParams.FILL_PARENT,
+ LayoutParams.FILL_PARENT);
+ layoutParams.gravity = Gravity.CENTER;
+ progressBar.setLayoutParams(layoutParams);
+ body.addView(progressBar);
+
return body;
}
private void addSectionDivider() {
View view = new View(getContext());
MarginLayoutParams mlp = new MarginLayoutParams(LayoutParams.FILL_PARENT, 1);
- mlp.setMargins(10, 5, 10, 5);
+ mlp.setMargins(10, 20, 10, 20);
view.setLayoutParams(mlp);
view.setBackgroundResource(R.drawable.black_white_gradient);
addView(view);