From 89767a10a4fed8bf8d942bbf867206d33c8af769 Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 13:25:08 -0700
Subject: [PATCH 01/34] Started with billing classes
---
astrid/AndroidManifest.xml | 15 +-
.../billing/IMarketBillingService.aidl | 24 +
.../com/todoroo/astrid/billing/Base64.java | 571 +++++++++++++++
.../billing/Base64DecoderException.java | 32 +
.../astrid/billing/BillingConstants.java | 92 +++
.../astrid/billing/BillingReceiver.java | 15 +
.../astrid/billing/BillingService.java | 654 ++++++++++++++++++
.../astrid/billing/PurchaseObserver.java | 162 +++++
.../astrid/billing/ResponseHandler.java | 169 +++++
.../com/todoroo/astrid/billing/Security.java | 247 +++++++
10 files changed, 1980 insertions(+), 1 deletion(-)
create mode 100644 astrid/src/com/android/vending/billing/IMarketBillingService.aidl
create mode 100644 astrid/src/com/todoroo/astrid/billing/Base64.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/Base64DecoderException.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/BillingConstants.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/BillingService.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
create mode 100644 astrid/src/com/todoroo/astrid/billing/Security.java
diff --git a/astrid/AndroidManifest.xml b/astrid/AndroidManifest.xml
index c5e9e67fc..9806b3cb1 100644
--- a/astrid/AndroidManifest.xml
+++ b/astrid/AndroidManifest.xml
@@ -37,6 +37,9 @@
+
+
+
@@ -507,7 +510,17 @@
-
+
+
+
+
+
+
+
+
+
+
+
+ * I am placing this code in the Public Domain. Do with it as you will.
+ * This software comes with no guarantees or warranties but with
+ * plenty of well-wishing instead!
+ * Please visit
+ * http://iharder.net/xmlizable
+ * periodically to check for updates or to contribute improvements.
+ *
+ *
+ * @author Robert Harder
+ * @author rharder@usa.net
+ * @version 1.3
+ */
+
+/**
+ * Base64 converter class. This code is not a complete MIME encoder;
+ * it simply converts binary data to base64 data and back.
+ *
+ *
Note {@link CharBase64} is a GWT-compatible implementation of this
+ * class.
+ */
+public class Base64 {
+ /** Specify encoding (value is {@code true}). */
+ public final static boolean ENCODE = true;
+
+ /** Specify decoding (value is {@code false}). */
+ public final static boolean DECODE = false;
+
+ /** The equals sign (=) as a byte. */
+ private final static byte EQUALS_SIGN = (byte) '=';
+
+ /** The new line character (\n) as a byte. */
+ private final static byte NEW_LINE = (byte) '\n';
+
+ /**
+ * The 64 valid Base64 values.
+ */
+ private final static byte[] ALPHABET =
+ {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
+ (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
+ (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
+ (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
+ (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
+ (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
+ (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
+ (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
+ (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
+ (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
+ (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
+ (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
+ (byte) '9', (byte) '+', (byte) '/'};
+
+ /**
+ * The 64 valid web safe Base64 values.
+ */
+ private final static byte[] WEBSAFE_ALPHABET =
+ {(byte) 'A', (byte) 'B', (byte) 'C', (byte) 'D', (byte) 'E', (byte) 'F',
+ (byte) 'G', (byte) 'H', (byte) 'I', (byte) 'J', (byte) 'K',
+ (byte) 'L', (byte) 'M', (byte) 'N', (byte) 'O', (byte) 'P',
+ (byte) 'Q', (byte) 'R', (byte) 'S', (byte) 'T', (byte) 'U',
+ (byte) 'V', (byte) 'W', (byte) 'X', (byte) 'Y', (byte) 'Z',
+ (byte) 'a', (byte) 'b', (byte) 'c', (byte) 'd', (byte) 'e',
+ (byte) 'f', (byte) 'g', (byte) 'h', (byte) 'i', (byte) 'j',
+ (byte) 'k', (byte) 'l', (byte) 'm', (byte) 'n', (byte) 'o',
+ (byte) 'p', (byte) 'q', (byte) 'r', (byte) 's', (byte) 't',
+ (byte) 'u', (byte) 'v', (byte) 'w', (byte) 'x', (byte) 'y',
+ (byte) 'z', (byte) '0', (byte) '1', (byte) '2', (byte) '3',
+ (byte) '4', (byte) '5', (byte) '6', (byte) '7', (byte) '8',
+ (byte) '9', (byte) '-', (byte) '_'};
+
+ /**
+ * Translates a Base64 value to either its 6-bit reconstruction value
+ * or a negative number indicating some other meaning.
+ **/
+ private final static byte[] DECODABET = {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8
+ -5, -5, // Whitespace: Tab and Linefeed
+ -9, -9, // Decimal 11 - 12
+ -5, // Whitespace: Carriage Return
+ -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
+ -9, -9, -9, -9, -9, // Decimal 27 - 31
+ -5, // Whitespace: Space
+ -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 42
+ 62, // Plus sign at decimal 43
+ -9, -9, -9, // Decimal 44 - 46
+ 63, // Slash at decimal 47
+ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
+ -9, -9, -9, // Decimal 58 - 60
+ -1, // Equals sign at decimal 61
+ -9, -9, -9, // Decimal 62 - 64
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
+ 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
+ -9, -9, -9, -9, -9, -9, // Decimal 91 - 96
+ 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
+ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
+ -9, -9, -9, -9, -9 // Decimal 123 - 127
+ /* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */
+ };
+
+ /** The web safe decodabet */
+ private final static byte[] WEBSAFE_DECODABET =
+ {-9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 0 - 8
+ -5, -5, // Whitespace: Tab and Linefeed
+ -9, -9, // Decimal 11 - 12
+ -5, // Whitespace: Carriage Return
+ -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 14 - 26
+ -9, -9, -9, -9, -9, // Decimal 27 - 31
+ -5, // Whitespace: Space
+ -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, -9, // Decimal 33 - 44
+ 62, // Dash '-' sign at decimal 45
+ -9, -9, // Decimal 46-47
+ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, // Numbers zero through nine
+ -9, -9, -9, // Decimal 58 - 60
+ -1, // Equals sign at decimal 61
+ -9, -9, -9, // Decimal 62 - 64
+ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, // Letters 'A' through 'N'
+ 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, // Letters 'O' through 'Z'
+ -9, -9, -9, -9, // Decimal 91-94
+ 63, // Underscore '_' at decimal 95
+ -9, // Decimal 96
+ 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, // Letters 'a' through 'm'
+ 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, // Letters 'n' through 'z'
+ -9, -9, -9, -9, -9 // Decimal 123 - 127
+ /* ,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 128 - 139
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 140 - 152
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 153 - 165
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 166 - 178
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 179 - 191
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 192 - 204
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 205 - 217
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 218 - 230
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9, // Decimal 231 - 243
+ -9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9,-9 // Decimal 244 - 255 */
+ };
+
+ // Indicates white space in encoding
+ private final static byte WHITE_SPACE_ENC = -5;
+ // Indicates equals sign in encoding
+ private final static byte EQUALS_SIGN_ENC = -1;
+
+ /** Defeats instantiation. */
+ private Base64() {
+ }
+
+ /* ******** E N C O D I N G M E T H O D S ******** */
+
+ /**
+ * Encodes up to three bytes of the array source
+ * and writes the resulting four Base64 bytes to destination.
+ * The source and destination arrays can be manipulated
+ * anywhere along their length by specifying
+ * srcOffset and destOffset.
+ * This method does not check to make sure your arrays
+ * are large enough to accommodate srcOffset + 3 for
+ * the source array or destOffset + 4 for
+ * the destination array.
+ * The actual number of significant bytes in your array is
+ * given by numSigBytes.
+ *
+ * @param source the array to convert
+ * @param srcOffset the index where conversion begins
+ * @param numSigBytes the number of significant bytes in your array
+ * @param destination the array to hold the conversion
+ * @param destOffset the index where output will be put
+ * @param alphabet is the encoding alphabet
+ * @return the destination array
+ * @since 1.3
+ */
+ private static byte[] encode3to4(byte[] source, int srcOffset,
+ int numSigBytes, byte[] destination, int destOffset, byte[] alphabet) {
+ // 1 2 3
+ // 01234567890123456789012345678901 Bit position
+ // --------000000001111111122222222 Array position from threeBytes
+ // --------| || || || | Six bit groups to index alphabet
+ // >>18 >>12 >> 6 >> 0 Right shift necessary
+ // 0x3f 0x3f 0x3f Additional AND
+
+ // Create buffer with zero-padding if there are only one or two
+ // significant bytes passed in the array.
+ // We have to shift left 24 in order to flush out the 1's that appear
+ // when Java treats a value as negative that is cast from a byte to an int.
+ int inBuff =
+ (numSigBytes > 0 ? ((source[srcOffset] << 24) >>> 8) : 0)
+ | (numSigBytes > 1 ? ((source[srcOffset + 1] << 24) >>> 16) : 0)
+ | (numSigBytes > 2 ? ((source[srcOffset + 2] << 24) >>> 24) : 0);
+
+ switch (numSigBytes) {
+ case 3:
+ destination[destOffset] = alphabet[(inBuff >>> 18)];
+ destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+ destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+ destination[destOffset + 3] = alphabet[(inBuff) & 0x3f];
+ return destination;
+ case 2:
+ destination[destOffset] = alphabet[(inBuff >>> 18)];
+ destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+ destination[destOffset + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+ destination[destOffset + 3] = EQUALS_SIGN;
+ return destination;
+ case 1:
+ destination[destOffset] = alphabet[(inBuff >>> 18)];
+ destination[destOffset + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+ destination[destOffset + 2] = EQUALS_SIGN;
+ destination[destOffset + 3] = EQUALS_SIGN;
+ return destination;
+ default:
+ return destination;
+ } // end switch
+ } // end encode3to4
+
+ /**
+ * Encodes a byte array into Base64 notation.
+ * Equivalent to calling
+ * {@code encodeBytes(source, 0, source.length)}
+ *
+ * @param source The data to convert
+ * @since 1.4
+ */
+ public static String encode(byte[] source) {
+ return encode(source, 0, source.length, ALPHABET, true);
+ }
+
+ /**
+ * Encodes a byte array into web safe Base64 notation.
+ *
+ * @param source The data to convert
+ * @param doPadding is {@code true} to pad result with '=' chars
+ * if it does not fall on 3 byte boundaries
+ */
+ public static String encodeWebSafe(byte[] source, boolean doPadding) {
+ return encode(source, 0, source.length, WEBSAFE_ALPHABET, doPadding);
+ }
+
+ /**
+ * Encodes a byte array into Base64 notation.
+ *
+ * @param source the data to convert
+ * @param off offset in array where conversion should begin
+ * @param len length of data to convert
+ * @param alphabet the encoding alphabet
+ * @param doPadding is {@code true} to pad result with '=' chars
+ * if it does not fall on 3 byte boundaries
+ * @since 1.4
+ */
+ public static String encode(byte[] source, int off, int len, byte[] alphabet,
+ boolean doPadding) {
+ byte[] outBuff = encode(source, off, len, alphabet, Integer.MAX_VALUE);
+ int outLen = outBuff.length;
+
+ // If doPadding is false, set length to truncate '='
+ // padding characters
+ while (doPadding == false && outLen > 0) {
+ if (outBuff[outLen - 1] != '=') {
+ break;
+ }
+ outLen -= 1;
+ }
+
+ return new String(outBuff, 0, outLen);
+ }
+
+ /**
+ * Encodes a byte array into Base64 notation.
+ *
+ * @param source the data to convert
+ * @param off offset in array where conversion should begin
+ * @param len length of data to convert
+ * @param alphabet is the encoding alphabet
+ * @param maxLineLength maximum length of one line.
+ * @return the BASE64-encoded byte array
+ */
+ public static byte[] encode(byte[] source, int off, int len, byte[] alphabet,
+ int maxLineLength) {
+ int lenDiv3 = (len + 2) / 3; // ceil(len / 3)
+ int len43 = lenDiv3 * 4;
+ byte[] outBuff = new byte[len43 // Main 4:3
+ + (len43 / maxLineLength)]; // New lines
+
+ int d = 0;
+ int e = 0;
+ int len2 = len - 2;
+ int lineLength = 0;
+ for (; d < len2; d += 3, e += 4) {
+
+ // The following block of code is the same as
+ // encode3to4( source, d + off, 3, outBuff, e, alphabet );
+ // but inlined for faster encoding (~20% improvement)
+ int inBuff =
+ ((source[d + off] << 24) >>> 8)
+ | ((source[d + 1 + off] << 24) >>> 16)
+ | ((source[d + 2 + off] << 24) >>> 24);
+ outBuff[e] = alphabet[(inBuff >>> 18)];
+ outBuff[e + 1] = alphabet[(inBuff >>> 12) & 0x3f];
+ outBuff[e + 2] = alphabet[(inBuff >>> 6) & 0x3f];
+ outBuff[e + 3] = alphabet[(inBuff) & 0x3f];
+
+ lineLength += 4;
+ if (lineLength == maxLineLength) {
+ outBuff[e + 4] = NEW_LINE;
+ e++;
+ lineLength = 0;
+ } // end if: end of line
+ } // end for: each piece of array
+
+ if (d < len) {
+ encode3to4(source, d + off, len - d, outBuff, e, alphabet);
+
+ lineLength += 4;
+ if (lineLength == maxLineLength) {
+ // Add a last newline
+ outBuff[e + 4] = NEW_LINE;
+ e++;
+ }
+ e += 4;
+ }
+
+ assert (e == outBuff.length);
+ return outBuff;
+ }
+
+
+ /* ******** D E C O D I N G M E T H O D S ******** */
+
+
+ /**
+ * Decodes four bytes from array source
+ * and writes the resulting bytes (up to three of them)
+ * to destination.
+ * The source and destination arrays can be manipulated
+ * anywhere along their length by specifying
+ * srcOffset and destOffset.
+ * This method does not check to make sure your arrays
+ * are large enough to accommodate srcOffset + 4 for
+ * the source array or destOffset + 3 for
+ * the destination array.
+ * This method returns the actual number of bytes that
+ * were converted from the Base64 encoding.
+ *
+ *
+ * @param source the array to convert
+ * @param srcOffset the index where conversion begins
+ * @param destination the array to hold the conversion
+ * @param destOffset the index where output will be put
+ * @param decodabet the decodabet for decoding Base64 content
+ * @return the number of decoded bytes converted
+ * @since 1.3
+ */
+ private static int decode4to3(byte[] source, int srcOffset,
+ byte[] destination, int destOffset, byte[] decodabet) {
+ // Example: Dk==
+ if (source[srcOffset + 2] == EQUALS_SIGN) {
+ int outBuff =
+ ((decodabet[source[srcOffset]] << 24) >>> 6)
+ | ((decodabet[source[srcOffset + 1]] << 24) >>> 12);
+
+ destination[destOffset] = (byte) (outBuff >>> 16);
+ return 1;
+ } else if (source[srcOffset + 3] == EQUALS_SIGN) {
+ // Example: DkL=
+ int outBuff =
+ ((decodabet[source[srcOffset]] << 24) >>> 6)
+ | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
+ | ((decodabet[source[srcOffset + 2]] << 24) >>> 18);
+
+ destination[destOffset] = (byte) (outBuff >>> 16);
+ destination[destOffset + 1] = (byte) (outBuff >>> 8);
+ return 2;
+ } else {
+ // Example: DkLE
+ int outBuff =
+ ((decodabet[source[srcOffset]] << 24) >>> 6)
+ | ((decodabet[source[srcOffset + 1]] << 24) >>> 12)
+ | ((decodabet[source[srcOffset + 2]] << 24) >>> 18)
+ | ((decodabet[source[srcOffset + 3]] << 24) >>> 24);
+
+ destination[destOffset] = (byte) (outBuff >> 16);
+ destination[destOffset + 1] = (byte) (outBuff >> 8);
+ destination[destOffset + 2] = (byte) (outBuff);
+ return 3;
+ }
+ } // end decodeToBytes
+
+
+ /**
+ * Decodes data from Base64 notation.
+ *
+ * @param s the string to decode (decoded in default encoding)
+ * @return the decoded data
+ * @since 1.4
+ */
+ public static byte[] decode(String s) throws Base64DecoderException {
+ byte[] bytes = s.getBytes();
+ return decode(bytes, 0, bytes.length);
+ }
+
+ /**
+ * Decodes data from web safe Base64 notation.
+ * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+ *
+ * @param s the string to decode (decoded in default encoding)
+ * @return the decoded data
+ */
+ public static byte[] decodeWebSafe(String s) throws Base64DecoderException {
+ byte[] bytes = s.getBytes();
+ return decodeWebSafe(bytes, 0, bytes.length);
+ }
+
+ /**
+ * Decodes Base64 content in byte array format and returns
+ * the decoded byte array.
+ *
+ * @param source The Base64 encoded data
+ * @return decoded data
+ * @since 1.3
+ * @throws Base64DecoderException
+ */
+ public static byte[] decode(byte[] source) throws Base64DecoderException {
+ return decode(source, 0, source.length);
+ }
+
+ /**
+ * Decodes web safe Base64 content in byte array format and returns
+ * the decoded data.
+ * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+ *
+ * @param source the string to decode (decoded in default encoding)
+ * @return the decoded data
+ */
+ public static byte[] decodeWebSafe(byte[] source)
+ throws Base64DecoderException {
+ return decodeWebSafe(source, 0, source.length);
+ }
+
+ /**
+ * Decodes Base64 content in byte array format and returns
+ * the decoded byte array.
+ *
+ * @param source the Base64 encoded data
+ * @param off the offset of where to begin decoding
+ * @param len the length of characters to decode
+ * @return decoded data
+ * @since 1.3
+ * @throws Base64DecoderException
+ */
+ public static byte[] decode(byte[] source, int off, int len)
+ throws Base64DecoderException {
+ return decode(source, off, len, DECODABET);
+ }
+
+ /**
+ * Decodes web safe Base64 content in byte array format and returns
+ * the decoded byte array.
+ * Web safe encoding uses '-' instead of '+', '_' instead of '/'
+ *
+ * @param source the Base64 encoded data
+ * @param off the offset of where to begin decoding
+ * @param len the length of characters to decode
+ * @return decoded data
+ */
+ public static byte[] decodeWebSafe(byte[] source, int off, int len)
+ throws Base64DecoderException {
+ return decode(source, off, len, WEBSAFE_DECODABET);
+ }
+
+ /**
+ * Decodes Base64 content using the supplied decodabet and returns
+ * the decoded byte array.
+ *
+ * @param source the Base64 encoded data
+ * @param off the offset of where to begin decoding
+ * @param len the length of characters to decode
+ * @param decodabet the decodabet for decoding Base64 content
+ * @return decoded data
+ */
+ @SuppressWarnings("nls")
+ public static byte[] decode(byte[] source, int off, int len, byte[] decodabet)
+ throws Base64DecoderException {
+ int len34 = len * 3 / 4;
+ byte[] outBuff = new byte[2 + len34]; // Upper limit on size of output
+ int outBuffPosn = 0;
+
+ byte[] b4 = new byte[4];
+ int b4Posn = 0;
+ int i = 0;
+ byte sbiCrop = 0;
+ byte sbiDecode = 0;
+ for (i = 0; i < len; i++) {
+ sbiCrop = (byte) (source[i + off] & 0x7f); // Only the low seven bits
+ sbiDecode = decodabet[sbiCrop];
+
+ if (sbiDecode >= WHITE_SPACE_ENC) { // White space Equals sign or better
+ if (sbiDecode >= EQUALS_SIGN_ENC) {
+ // An equals sign (for padding) must not occur at position 0 or 1
+ // and must be the last byte[s] in the encoded value
+ if (sbiCrop == EQUALS_SIGN) {
+ int bytesLeft = len - i;
+ byte lastByte = (byte) (source[len - 1 + off] & 0x7f);
+ if (b4Posn == 0 || b4Posn == 1) {
+ throw new Base64DecoderException(
+ "invalid padding byte '=' at byte offset " + i);
+ } else if ((b4Posn == 3 && bytesLeft > 2)
+ || (b4Posn == 4 && bytesLeft > 1)) {
+ throw new Base64DecoderException(
+ "padding byte '=' falsely signals end of encoded value "
+ + "at offset " + i);
+ } else if (lastByte != EQUALS_SIGN && lastByte != NEW_LINE) {
+ throw new Base64DecoderException(
+ "encoded value has invalid trailing byte");
+ }
+ break;
+ }
+
+ b4[b4Posn++] = sbiCrop;
+ if (b4Posn == 4) {
+ outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
+ b4Posn = 0;
+ }
+ }
+ } else {
+ throw new Base64DecoderException("Bad Base64 input character at " + i
+ + ": " + source[i + off] + "(decimal)");
+ }
+ }
+
+ // Because web safe encoding allows non padding base64 encodes, we
+ // need to pad the rest of the b4 buffer with equal signs when
+ // b4Posn != 0. There can be at most 2 equal signs at the end of
+ // four characters, so the b4 buffer must have two or three
+ // characters. This also catches the case where the input is
+ // padded with EQUALS_SIGN
+ if (b4Posn != 0) {
+ if (b4Posn == 1) {
+ throw new Base64DecoderException("single trailing character at offset "
+ + (len - 1));
+ }
+ b4[b4Posn++] = EQUALS_SIGN;
+ outBuffPosn += decode4to3(b4, 0, outBuff, outBuffPosn, decodabet);
+ }
+
+ byte[] out = new byte[outBuffPosn];
+ System.arraycopy(outBuff, 0, out, 0, outBuffPosn);
+ return out;
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/Base64DecoderException.java b/astrid/src/com/todoroo/astrid/billing/Base64DecoderException.java
new file mode 100644
index 000000000..af51d1c0f
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/Base64DecoderException.java
@@ -0,0 +1,32 @@
+// Copyright 2002, Google, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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.
+
+package com.todoroo.astrid.billing;
+
+/**
+ * Exception thrown when encountering an invalid Base64 input character.
+ *
+ * @author nelson
+ */
+public class Base64DecoderException extends Exception {
+ public Base64DecoderException() {
+ super();
+ }
+
+ public Base64DecoderException(String s) {
+ super(s);
+ }
+
+ private static final long serialVersionUID = 1L;
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
new file mode 100644
index 000000000..06c0c3b3e
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
@@ -0,0 +1,92 @@
+package com.todoroo.astrid.billing;
+
+
+@SuppressWarnings("nls")
+public class BillingConstants {
+
+ /** This is the action we use to bind to the MarketBillingService. */
+ public static final String MARKET_BILLING_SERVICE_ACTION =
+ "com.android.vending.billing.MarketBillingService.BIND";
+
+ // Intent actions that we send from the BillingReceiver to the
+ // BillingService. Defined by this application.
+ public static final String ACTION_CONFIRM_NOTIFICATION =
+ "com.example.subscriptions.CONFIRM_NOTIFICATION";
+ public static final String ACTION_GET_PURCHASE_INFORMATION =
+ "com.example.subscriptions.GET_PURCHASE_INFORMATION";
+ public static final String ACTION_RESTORE_TRANSACTIONS =
+ "com.example.subscriptions.RESTORE_TRANSACTIONS";
+
+ // Intent actions that we receive in the BillingReceiver from Market.
+ // These are defined by Market and cannot be changed.
+ public static final String ACTION_NOTIFY = "com.android.vending.billing.IN_APP_NOTIFY";
+ public static final String ACTION_RESPONSE_CODE =
+ "com.android.vending.billing.RESPONSE_CODE";
+ public static final String ACTION_PURCHASE_STATE_CHANGED =
+ "com.android.vending.billing.PURCHASE_STATE_CHANGED";
+
+ // These are the names of the extras that are passed in an intent from
+ // Market to this application and cannot be changed.
+ public static final String NOTIFICATION_ID = "notification_id";
+ public static final String INAPP_SIGNED_DATA = "inapp_signed_data";
+ public static final String INAPP_SIGNATURE = "inapp_signature";
+ public static final String INAPP_REQUEST_ID = "request_id";
+ public static final String INAPP_RESPONSE_CODE = "response_code";
+
+ // These are the names of the fields in the request bundle.
+ public static final String BILLING_REQUEST_METHOD = "BILLING_REQUEST";
+ public static final String BILLING_REQUEST_API_VERSION = "API_VERSION";
+ public static final String BILLING_REQUEST_PACKAGE_NAME = "PACKAGE_NAME";
+ public static final String BILLING_REQUEST_ITEM_ID = "ITEM_ID";
+ public static final String BILLING_REQUEST_ITEM_TYPE = "ITEM_TYPE";
+ public static final String BILLING_REQUEST_DEVELOPER_PAYLOAD = "DEVELOPER_PAYLOAD";
+ public static final String BILLING_REQUEST_NOTIFY_IDS = "NOTIFY_IDS";
+ public static final String BILLING_REQUEST_NONCE = "NONCE";
+
+ public static final String BILLING_RESPONSE_RESPONSE_CODE = "RESPONSE_CODE";
+ public static final String BILLING_RESPONSE_PURCHASE_INTENT = "PURCHASE_INTENT";
+ public static final String BILLING_RESPONSE_REQUEST_ID = "REQUEST_ID";
+ public static final long BILLING_RESPONSE_INVALID_REQUEST_ID = -1;
+
+ // These are the types supported in the IAB v2
+ public static final String ITEM_TYPE_INAPP = "inapp";
+ public static final String ITEM_TYPE_SUBSCRIPTION = "subs";
+
+
+ // The response codes for a request, defined by Android Market.
+ public enum ResponseCode {
+ RESULT_OK,
+ RESULT_USER_CANCELED,
+ RESULT_SERVICE_UNAVAILABLE,
+ RESULT_BILLING_UNAVAILABLE,
+ RESULT_ITEM_UNAVAILABLE,
+ RESULT_DEVELOPER_ERROR,
+ RESULT_ERROR;
+
+ // Converts from an ordinal value to the ResponseCode
+ public static ResponseCode valueOf(int index) {
+ ResponseCode[] values = ResponseCode.values();
+ if (index < 0 || index >= values.length) {
+ return RESULT_ERROR;
+ }
+ return values[index];
+ }
+ }
+
+ // The possible states of an in-app purchase, as defined by Android Market.
+ public enum PurchaseState {
+ // Responses to requestPurchase or restoreTransactions.
+ PURCHASED, // User was charged for the order.
+ CANCELED, // The charge failed on the server.
+ REFUNDED; // User received a refund for the order.
+
+ // Converts from an ordinal value to the PurchaseState
+ public static PurchaseState valueOf(int index) {
+ PurchaseState[] values = PurchaseState.values();
+ if (index < 0 || index >= values.length) {
+ return CANCELED;
+ }
+ return values[index];
+ }
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java b/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
new file mode 100644
index 000000000..1708989da
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
@@ -0,0 +1,15 @@
+package com.todoroo.astrid.billing;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+
+public class BillingReceiver extends BroadcastReceiver {
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // TODO Auto-generated method stub
+
+ }
+
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingService.java b/astrid/src/com/todoroo/astrid/billing/BillingService.java
new file mode 100644
index 000000000..207f79ae4
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/BillingService.java
@@ -0,0 +1,654 @@
+package com.todoroo.astrid.billing;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.util.Log;
+
+import com.android.vending.billing.IMarketBillingService;
+import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
+import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
+import com.todoroo.astrid.billing.Security.VerifiedPurchase;
+import com.todoroo.astrid.utility.Constants;
+
+@SuppressWarnings("nls")
+public class BillingService extends Service implements ServiceConnection {
+ private static final String TAG = "billing-service";
+
+ /** The service connection to the remote MarketBillingService. */
+ private static IMarketBillingService mService;
+
+ /**
+ * The list of requests that are pending while we are waiting for the
+ * connection to the MarketBillingService to be established.
+ */
+ private static LinkedList mPendingRequests = new LinkedList();
+
+ /**
+ * The list of requests that we have sent to Android Market but for which we have
+ * not yet received a response code. The HashMap is indexed by the
+ * request Id that each request receives when it executes.
+ */
+ private static HashMap mSentRequests =
+ new HashMap();
+
+ /**
+ * The base class for all requests that use the MarketBillingService.
+ * Each derived class overrides the run() method to call the appropriate
+ * service interface. If we are already connected to the MarketBillingService,
+ * then we call the run() method directly. Otherwise, we bind
+ * to the service and save the request on a queue to be run later when
+ * the service is connected.
+ */
+ abstract class BillingRequest {
+ private final int mStartId;
+ protected long mRequestId;
+
+ public BillingRequest(int startId) {
+ mStartId = startId;
+ }
+
+ public int getStartId() {
+ return mStartId;
+ }
+
+ /**
+ * Run the request, starting the connection if necessary.
+ * @return true if the request was executed or queued; false if there
+ * was an error starting the connection
+ */
+ public boolean runRequest() {
+ if (runIfConnected()) {
+ return true;
+ }
+
+ if (bindToMarketBillingService()) {
+ // Add a pending request to run when the service is connected.
+ mPendingRequests.add(this);
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Try running the request directly if the service is already connected.
+ * @return true if the request ran successfully; false if the service
+ * is not connected or there was an error when trying to use it
+ */
+ public boolean runIfConnected() {
+ if (Constants.DEBUG) {
+ Log.d(TAG, getClass().getSimpleName());
+ }
+ if (mService != null) {
+ try {
+ mRequestId = run();
+ if (Constants.DEBUG) {
+ Log.d(TAG, "request id: " + mRequestId);
+ }
+ if (mRequestId >= 0) {
+ mSentRequests.put(mRequestId, this);
+ }
+ return true;
+ } catch (RemoteException e) {
+ onRemoteException(e);
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Called when a remote exception occurs while trying to execute the
+ * {@link #run()} method. The derived class can override this to
+ * execute exception-handling code.
+ * @param e the exception
+ */
+ protected void onRemoteException(RemoteException e) {
+ Log.w(TAG, "remote billing service crashed");
+ mService = null;
+ }
+
+ /**
+ * The derived class must implement this method.
+ * @throws RemoteException
+ */
+ abstract protected long run() throws RemoteException;
+
+ /**
+ * This is called when Android Market sends a response code for this
+ * request.
+ * @param responseCode the response code
+ */
+ protected void responseCodeReceived(ResponseCode responseCode) {
+ //
+ }
+
+ protected Bundle makeRequestBundle(String method) {
+ Bundle request = new Bundle();
+ request.putString(BillingConstants.BILLING_REQUEST_METHOD, method);
+ request.putInt(BillingConstants.BILLING_REQUEST_API_VERSION, 2);
+ request.putString(BillingConstants.BILLING_REQUEST_PACKAGE_NAME, getPackageName());
+ return request;
+ }
+
+ protected void logResponseCode(String method, Bundle response) {
+ ResponseCode responseCode = ResponseCode.valueOf(
+ response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE));
+ if (Constants.DEBUG) {
+ Log.e(TAG, method + " received " + responseCode.toString());
+ }
+ }
+ }
+
+ /**
+ * Wrapper class that checks if in-app billing is supported.
+ *
+ * Note: Support for subscriptions implies support for one-time purchases. However, the opposite
+ * is not true.
+ *
+ * Developers may want to perform two checks if both one-time and subscription products are
+ * available.
+ */
+ class CheckBillingSupported extends BillingRequest {
+ public String mProductType = null;
+
+// /** Legacy contrustor
+// *
+// * This constructor is provided for legacy purposes. Assumes the calling application will
+// * not be using any features not present in API v1, such as subscriptions.
+// */
+// @Deprecated
+// public CheckBillingSupported() {
+// // This object is never created as a side effect of starting this
+// // service so we pass -1 as the startId to indicate that we should
+// // not stop this service after executing this request.
+// super(-1);
+// }
+
+ /** Constructor
+ *
+ * Note: Support for subscriptions implies support for one-time purchases. However, the
+ * opposite is not true.
+ *
+ * Developers may want to perform two checks if both one-time and subscription products are
+ * available.
+ *
+ * @pram itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating
+ * the type of item support is being checked for.
+ */
+ public CheckBillingSupported(String itemType) {
+ super(-1);
+ mProductType = itemType;
+ }
+
+ @Override
+ protected long run() throws RemoteException {
+ Bundle request = makeRequestBundle("CHECK_BILLING_SUPPORTED");
+ if (mProductType != null) {
+ request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType);
+ }
+ Bundle response = mService.sendBillingRequest(request);
+ int responseCode = response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE);
+ if (Constants.DEBUG) {
+ Log.i(TAG, "CheckBillingSupported response code: " +
+ ResponseCode.valueOf(responseCode));
+ }
+ boolean billingSupported = (responseCode == ResponseCode.RESULT_OK.ordinal());
+ ResponseHandler.checkBillingSupportedResponse(billingSupported, mProductType);
+ return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID;
+ }
+ }
+
+ /**
+ * Wrapper class that requests a purchase.
+ */
+ class RequestPurchase extends BillingRequest {
+ public final String mProductId;
+ public final String mDeveloperPayload;
+ public final String mProductType;
+
+// /** Legacy constructor
+// *
+// * @param itemId The ID of the item to be purchased. Will be assumed to be a one-time
+// * purchase.
+// */
+// @Deprecated
+// public RequestPurchase(String itemId) {
+// this(itemId, null, null);
+// }
+//
+// /** Legacy constructor
+// *
+// * @param itemId The ID of the item to be purchased. Will be assumed to be a one-time
+// * purchase.
+// * @param developerPayload Optional data.
+// */
+// @Deprecated
+// public RequestPurchase(String itemId, String developerPayload) {
+// this(itemId, null, developerPayload);
+// }
+
+ /** Constructor
+ *
+ * @param itemId The ID of the item to be purchased. Will be assumed to be a one-time
+ * purchase.
+ * @param itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION,
+ * indicating the type of item type support is being checked for.
+ * @param developerPayload Optional data.
+ */
+ public RequestPurchase(String itemId, String itemType, String developerPayload) {
+ // This object is never created as a side effect of starting this
+ // service so we pass -1 as the startId to indicate that we should
+ // not stop this service after executing this request.
+ super(-1);
+ mProductId = itemId;
+ mDeveloperPayload = developerPayload;
+ mProductType = itemType;
+ }
+
+ @Override
+ protected long run() throws RemoteException {
+ Bundle request = makeRequestBundle("REQUEST_PURCHASE");
+ request.putString(BillingConstants.BILLING_REQUEST_ITEM_ID, mProductId);
+ request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType);
+ // Note that the developer payload is optional.
+ if (mDeveloperPayload != null) {
+ request.putString(BillingConstants.BILLING_REQUEST_DEVELOPER_PAYLOAD, mDeveloperPayload);
+ }
+ Bundle response = mService.sendBillingRequest(request);
+ PendingIntent pendingIntent
+ = response.getParcelable(BillingConstants.BILLING_RESPONSE_PURCHASE_INTENT);
+ if (pendingIntent == null) {
+ Log.e(TAG, "Error with requestPurchase");
+ return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID;
+ }
+
+ Intent intent = new Intent();
+ ResponseHandler.buyPageIntentResponse(pendingIntent, intent);
+ return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
+ BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
+ }
+
+ @Override
+ protected void responseCodeReceived(ResponseCode responseCode) {
+ ResponseHandler.responseCodeReceived(BillingService.this, this, responseCode);
+ }
+ }
+
+ /**
+ * Wrapper class that confirms a list of notifications to the server.
+ */
+ class ConfirmNotifications extends BillingRequest {
+ final String[] mNotifyIds;
+
+ public ConfirmNotifications(int startId, String[] notifyIds) {
+ super(startId);
+ mNotifyIds = notifyIds;
+ }
+
+ @Override
+ protected long run() throws RemoteException {
+ Bundle request = makeRequestBundle("CONFIRM_NOTIFICATIONS");
+ request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds);
+ Bundle response = mService.sendBillingRequest(request);
+ logResponseCode("confirmNotifications", response);
+ return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
+ BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
+ }
+ }
+
+ /**
+ * Wrapper class that sends a GET_PURCHASE_INFORMATION message to the server.
+ */
+ class GetPurchaseInformation extends BillingRequest {
+ long mNonce;
+ final String[] mNotifyIds;
+
+ public GetPurchaseInformation(int startId, String[] notifyIds) {
+ super(startId);
+ mNotifyIds = notifyIds;
+ }
+
+ @Override
+ protected long run() throws RemoteException {
+ mNonce = Security.generateNonce();
+
+ Bundle request = makeRequestBundle("GET_PURCHASE_INFORMATION");
+ request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce);
+ request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds);
+ Bundle response = mService.sendBillingRequest(request);
+ logResponseCode("getPurchaseInformation", response);
+ return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
+ BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
+ }
+
+ @Override
+ protected void onRemoteException(RemoteException e) {
+ super.onRemoteException(e);
+ Security.removeNonce(mNonce);
+ }
+ }
+
+ /**
+ * Wrapper class that sends a RESTORE_TRANSACTIONS message to the server.
+ */
+ class RestoreTransactions extends BillingRequest {
+ long mNonce;
+
+ public RestoreTransactions() {
+ // This object is never created as a side effect of starting this
+ // service so we pass -1 as the startId to indicate that we should
+ // not stop this service after executing this request.
+ super(-1);
+ }
+
+ @Override
+ protected long run() throws RemoteException {
+ mNonce = Security.generateNonce();
+
+ Bundle request = makeRequestBundle("RESTORE_TRANSACTIONS");
+ request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce);
+ Bundle response = mService.sendBillingRequest(request);
+ logResponseCode("restoreTransactions", response);
+ return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
+ BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
+ }
+
+ @Override
+ protected void onRemoteException(RemoteException e) {
+ super.onRemoteException(e);
+ Security.removeNonce(mNonce);
+ }
+
+ @Override
+ protected void responseCodeReceived(ResponseCode responseCode) {
+ ResponseHandler.responseCodeReceived(BillingService.this, this, responseCode);
+ }
+ }
+
+ public BillingService() {
+ super();
+ }
+
+ public void setContext(Context context) {
+ attachBaseContext(context);
+ }
+
+ /**
+ * We don't support binding to this service, only starting the service.
+ */
+ @Override
+ public IBinder onBind(Intent intent) {
+ return null;
+ }
+
+ @Override
+ public void onStart(Intent intent, int startId) {
+ handleCommand(intent, startId);
+ }
+
+ /**
+ * The {@link BillingReceiver} sends messages to this service using intents.
+ * Each intent has an action and some extra arguments specific to that action.
+ * @param intent the intent containing one of the supported actions
+ * @param startId an identifier for the invocation instance of this service
+ */
+ public void handleCommand(Intent intent, int startId) {
+ String action = intent.getAction();
+ if (Constants.DEBUG) {
+ Log.i(TAG, "handleCommand() action: " + action);
+ }
+ if (BillingConstants.ACTION_CONFIRM_NOTIFICATION.equals(action)) {
+ String[] notifyIds = intent.getStringArrayExtra(BillingConstants.NOTIFICATION_ID);
+ confirmNotifications(startId, notifyIds);
+ } else if (BillingConstants.ACTION_GET_PURCHASE_INFORMATION.equals(action)) {
+ String notifyId = intent.getStringExtra(BillingConstants.NOTIFICATION_ID);
+ getPurchaseInformation(startId, new String[] { notifyId });
+ } else if (BillingConstants.ACTION_PURCHASE_STATE_CHANGED.equals(action)) {
+ String signedData = intent.getStringExtra(BillingConstants.INAPP_SIGNED_DATA);
+ String signature = intent.getStringExtra(BillingConstants.INAPP_SIGNATURE);
+ purchaseStateChanged(startId, signedData, signature);
+ } else if (BillingConstants.ACTION_RESPONSE_CODE.equals(action)) {
+ long requestId = intent.getLongExtra(BillingConstants.INAPP_REQUEST_ID, -1);
+ int responseCodeIndex = intent.getIntExtra(BillingConstants.INAPP_RESPONSE_CODE,
+ ResponseCode.RESULT_ERROR.ordinal());
+ ResponseCode responseCode = ResponseCode.valueOf(responseCodeIndex);
+ checkResponseCode(requestId, responseCode);
+ }
+ }
+
+ /**
+ * Binds to the MarketBillingService and returns true if the bind
+ * succeeded.
+ * @return true if the bind succeeded; false otherwise
+ */
+ private boolean bindToMarketBillingService() {
+ try {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "binding to Market billing service");
+ }
+ boolean bindResult = bindService(
+ new Intent(BillingConstants.MARKET_BILLING_SERVICE_ACTION),
+ this, // ServiceConnection.
+ Context.BIND_AUTO_CREATE);
+
+ if (bindResult) {
+ return true;
+ } else {
+ Log.e(TAG, "Could not bind to service.");
+ }
+ } catch (SecurityException e) {
+ Log.e(TAG, "Security exception: " + e);
+ }
+ return false;
+ }
+//
+// /**
+// * Checks if in-app billing is supported. Assumes this is a one-time purchase.
+// *
+// * @return true if supported; false otherwise
+// */
+// @Deprecated
+// public boolean checkBillingSupported() {
+// return new CheckBillingSupported().runRequest();
+// }
+
+ /**
+ * Checks if in-app billing is supported.
+ * @pram itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating the
+ * type of item support is being checked for.
+ * @return true if supported; false otherwise
+ */
+ public boolean checkBillingSupported(String itemType) {
+ return new CheckBillingSupported(itemType).runRequest();
+ }
+
+ /**
+ * Requests that the given item be offered to the user for purchase. When
+ * the purchase succeeds (or is canceled) the {@link BillingReceiver}
+ * receives an intent with the action {@link BillingConstants#ACTION_NOTIFY}.
+ * Returns false if there was an error trying to connect to Android Market.
+ * @param productId an identifier for the item being offered for purchase
+ * @param itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating
+ * the type of item type support is being checked for.
+ * @param developerPayload a payload that is associated with a given
+ * purchase, if null, no payload is sent
+ * @return false if there was an error connecting to Android Market
+ */
+ public boolean requestPurchase(String productId, String itemType, String developerPayload) {
+ return new RequestPurchase(productId, itemType, developerPayload).runRequest();
+ }
+
+ /**
+ * Requests transaction information for all managed items. Call this only when the
+ * application is first installed or after a database wipe. Do NOT call this
+ * every time the application starts up.
+ * @return false if there was an error connecting to Android Market
+ */
+ public boolean restoreTransactions() {
+ return new RestoreTransactions().runRequest();
+ }
+
+ /**
+ * Confirms receipt of a purchase state change. Each {@code notifyId} is
+ * an opaque identifier that came from the server. This method sends those
+ * identifiers back to the MarketBillingService, which ACKs them to the
+ * server. Returns false if there was an error trying to connect to the
+ * MarketBillingService.
+ * @param startId an identifier for the invocation instance of this service
+ * @param notifyIds a list of opaque identifiers associated with purchase
+ * state changes.
+ * @return false if there was an error connecting to Market
+ */
+ private boolean confirmNotifications(int startId, String[] notifyIds) {
+ return new ConfirmNotifications(startId, notifyIds).runRequest();
+ }
+
+ /**
+ * Gets the purchase information. This message includes a list of
+ * notification IDs sent to us by Android Market, which we include in
+ * our request. The server responds with the purchase information,
+ * encoded as a JSON string, and sends that to the {@link BillingReceiver}
+ * in an intent with the action {@link BillingConstants#ACTION_PURCHASE_STATE_CHANGED}.
+ * Returns false if there was an error trying to connect to the MarketBillingService.
+ *
+ * @param startId an identifier for the invocation instance of this service
+ * @param notifyIds a list of opaque identifiers associated with purchase
+ * state changes
+ * @return false if there was an error connecting to Android Market
+ */
+ private boolean getPurchaseInformation(int startId, String[] notifyIds) {
+ return new GetPurchaseInformation(startId, notifyIds).runRequest();
+ }
+
+ /**
+ * Verifies that the data was signed with the given signature, and calls
+ * {@link ResponseHandler#purchaseResponse(Context, PurchaseState, String, String, long)}
+ * for each verified purchase.
+ * @param startId an identifier for the invocation instance of this service
+ * @param signedData the signed JSON string (signed, not encrypted)
+ * @param signature the signature for the data, signed with the private key
+ */
+ private void purchaseStateChanged(int startId, String signedData, String signature) {
+ ArrayList purchases;
+ purchases = Security.verifyPurchase(signedData, signature);
+ if (purchases == null) {
+ return;
+ }
+
+ ArrayList notifyList = new ArrayList();
+ for (VerifiedPurchase vp : purchases) {
+ if (vp.notificationId != null) {
+ notifyList.add(vp.notificationId);
+ }
+ ResponseHandler.purchaseResponse(this, vp.purchaseState, vp.productId,
+ vp.orderId, vp.purchaseTime, vp.developerPayload);
+ }
+ if (!notifyList.isEmpty()) {
+ String[] notifyIds = notifyList.toArray(new String[notifyList.size()]);
+ confirmNotifications(startId, notifyIds);
+ }
+ }
+
+ /**
+ * This is called when we receive a response code from Android Market for a request
+ * that we made. This is used for reporting various errors and for
+ * acknowledging that an order was sent to the server. This is NOT used
+ * for any purchase state changes. All purchase state changes are received
+ * in the {@link BillingReceiver} and passed to this service, where they are
+ * handled in {@link #purchaseStateChanged(int, String, String)}.
+ * @param requestId a number that identifies a request, assigned at the
+ * time the request was made to Android Market
+ * @param responseCode a response code from Android Market to indicate the state
+ * of the request
+ */
+ private void checkResponseCode(long requestId, ResponseCode responseCode) {
+ BillingRequest request = mSentRequests.get(requestId);
+ if (request != null) {
+ if (Constants.DEBUG) {
+ Log.d(TAG, request.getClass().getSimpleName() + ": " + responseCode);
+ }
+ request.responseCodeReceived(responseCode);
+ }
+ mSentRequests.remove(requestId);
+ }
+
+ /**
+ * Runs any pending requests that are waiting for a connection to the
+ * service to be established. This runs in the main UI thread.
+ */
+ private void runPendingRequests() {
+ int maxStartId = -1;
+ BillingRequest request;
+ while ((request = mPendingRequests.peek()) != null) {
+ if (request.runIfConnected()) {
+ // Remove the request
+ mPendingRequests.remove();
+
+ // Remember the largest startId, which is the most recent
+ // request to start this service.
+ if (maxStartId < request.getStartId()) {
+ maxStartId = request.getStartId();
+ }
+ } else {
+ // The service crashed, so restart it. Note that this leaves
+ // the current request on the queue.
+ bindToMarketBillingService();
+ return;
+ }
+ }
+
+ // If we get here then all the requests ran successfully. If maxStartId
+ // is not -1, then one of the requests started the service, so we can
+ // stop it now.
+ if (maxStartId >= 0) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "stopping service, startId: " + maxStartId);
+ }
+ stopSelf(maxStartId);
+ }
+ }
+
+ /**
+ * This is called when we are connected to the MarketBillingService.
+ * This runs in the main UI thread.
+ */
+ @Override
+ public void onServiceConnected(ComponentName name, IBinder service) {
+ if (Constants.DEBUG) {
+ Log.d(TAG, "Billing service connected");
+ }
+ mService = IMarketBillingService.Stub.asInterface(service);
+ runPendingRequests();
+ }
+
+ /**
+ * This is called when we are disconnected from the MarketBillingService.
+ */
+ @Override
+ public void onServiceDisconnected(ComponentName name) {
+ Log.w(TAG, "Billing service disconnected");
+ mService = null;
+ }
+
+ /**
+ * Unbinds from the MarketBillingService. Call this when the application
+ * terminates to avoid leaking a ServiceConnection.
+ */
+ public void unbind() {
+ try {
+ unbindService(this);
+ } catch (IllegalArgumentException e) {
+ // This might happen if the service was disconnected
+ }
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
new file mode 100644
index 000000000..2e11d1809
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
@@ -0,0 +1,162 @@
+// Copyright 2010 Google Inc. All Rights Reserved.
+
+package com.todoroo.astrid.billing;
+
+import java.lang.reflect.Method;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.app.PendingIntent.CanceledException;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentSender;
+import android.os.Handler;
+import android.util.Log;
+
+import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
+import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
+import com.todoroo.astrid.billing.BillingService.RequestPurchase;
+import com.todoroo.astrid.billing.BillingService.RestoreTransactions;
+
+/**
+ * An interface for observing changes related to purchases. The main application
+ * extends this class and registers an instance of that derived class with
+ * {@link ResponseHandler}. The main application implements the callbacks
+ * {@link #onBillingSupported(boolean)} and
+ * {@link #onPurchaseStateChange(PurchaseState, String, int, long)}. These methods
+ * are used to update the UI.
+ */
+public abstract class PurchaseObserver {
+ private static final String TAG = "purchase-observer"; //$NON-NLS-1$
+ private final Activity mActivity;
+ private final Handler mHandler;
+ private Method mStartIntentSender;
+ private final Object[] mStartIntentSenderArgs = new Object[5];
+ private static final Class>[] START_INTENT_SENDER_SIG = new Class[] {
+ IntentSender.class, Intent.class, int.class, int.class, int.class
+ };
+
+ public PurchaseObserver(Activity activity, Handler handler) {
+ mActivity = activity;
+ mHandler = handler;
+ initCompatibilityLayer();
+ }
+
+ /**
+ * This is the callback that is invoked when Android Market responds to the
+ * {@link BillingService#checkBillingSupported()} request.
+ * @param supported true if in-app billing is supported.
+ */
+ public abstract void onBillingSupported(boolean supported, String type);
+
+ /**
+ * This is the callback that is invoked when an item is purchased,
+ * refunded, or canceled. It is the callback invoked in response to
+ * calling {@link BillingService#requestPurchase(String)}. It may also
+ * be invoked asynchronously when a purchase is made on another device
+ * (if the purchase was for a Market-managed item), or if the purchase
+ * was refunded, or the charge was canceled. This handles the UI
+ * update. The database update is handled in
+ * {@link ResponseHandler#purchaseResponse(Context, PurchaseState,
+ * String, String, long)}.
+ * @param purchaseState the purchase state of the item
+ * @param itemId a string identifying the item (the "SKU")
+ * @param quantity the current quantity of this item after the purchase
+ * @param purchaseTime the time the product was purchased, in
+ * milliseconds since the epoch (Jan 1, 1970)
+ */
+ public abstract void onPurchaseStateChange(PurchaseState purchaseState,
+ String itemId, int quantity, long purchaseTime, String developerPayload);
+
+ /**
+ * This is called when we receive a response code from Market for a
+ * RequestPurchase request that we made. This is NOT used for any
+ * purchase state changes. All purchase state changes are received in
+ * {@link #onPurchaseStateChange(PurchaseState, String, int, long)}.
+ * This is used for reporting various errors, or if the user backed out
+ * and didn't purchase the item. The possible response codes are:
+ * RESULT_OK means that the order was sent successfully to the server.
+ * The onPurchaseStateChange() will be invoked later (with a
+ * purchase state of PURCHASED or CANCELED) when the order is
+ * charged or canceled. This response code can also happen if an
+ * order for a Market-managed item was already sent to the server.
+ * RESULT_USER_CANCELED means that the user didn't buy the item.
+ * RESULT_SERVICE_UNAVAILABLE means that we couldn't connect to the
+ * Android Market server (for example if the data connection is down).
+ * RESULT_BILLING_UNAVAILABLE means that in-app billing is not
+ * supported yet.
+ * RESULT_ITEM_UNAVAILABLE means that the item this app offered for
+ * sale does not exist (or is not published) in the server-side
+ * catalog.
+ * RESULT_ERROR is used for any other errors (such as a server error).
+ */
+ public abstract void onRequestPurchaseResponse(RequestPurchase request,
+ ResponseCode responseCode);
+
+ /**
+ * This is called when we receive a response code from Android Market for a
+ * RestoreTransactions request that we made. A response code of
+ * RESULT_OK means that the request was successfully sent to the server.
+ */
+ public abstract void onRestoreTransactionsResponse(RestoreTransactions request,
+ ResponseCode responseCode);
+
+ private void initCompatibilityLayer() {
+ try {
+ mStartIntentSender = mActivity.getClass().getMethod("startIntentSender", //$NON-NLS-1$
+ START_INTENT_SENDER_SIG);
+ } catch (SecurityException e) {
+ mStartIntentSender = null;
+ } catch (NoSuchMethodException e) {
+ mStartIntentSender = null;
+ }
+ }
+
+ void startBuyPageActivity(PendingIntent pendingIntent, Intent intent) {
+ if (mStartIntentSender != null) {
+ // This is on Android 2.0 and beyond. The in-app buy page activity
+ // must be on the activity stack of the application.
+ try {
+ // This implements the method call:
+ // mActivity.startIntentSender(pendingIntent.getIntentSender(),
+ // intent, 0, 0, 0);
+ mStartIntentSenderArgs[0] = pendingIntent.getIntentSender();
+ mStartIntentSenderArgs[1] = intent;
+ mStartIntentSenderArgs[2] = Integer.valueOf(0);
+ mStartIntentSenderArgs[3] = Integer.valueOf(0);
+ mStartIntentSenderArgs[4] = Integer.valueOf(0);
+ mStartIntentSender.invoke(mActivity, mStartIntentSenderArgs);
+ } catch (Exception e) {
+ Log.e(TAG, "error starting activity", e); //$NON-NLS-1$
+ }
+ } else {
+ // This is on Android version 1.6. The in-app buy page activity must be on its
+ // own separate activity stack instead of on the activity stack of
+ // the application.
+ try {
+ pendingIntent.send(mActivity, 0 /* code */, intent);
+ } catch (CanceledException e) {
+ Log.e(TAG, "error starting activity", e); //$NON-NLS-1$
+ }
+ }
+ }
+
+ /**
+ * Updates the UI after the database has been updated. This method runs
+ * in a background thread so it has to post a Runnable to run on the UI
+ * thread.
+ * @param purchaseState the purchase state of the item
+ * @param itemId a string identifying the item
+ * @param quantity the quantity of items in this purchase
+ */
+ void postPurchaseStateChange(final PurchaseState purchaseState, final String itemId,
+ final int quantity, final long purchaseTime, final String developerPayload) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ onPurchaseStateChange(
+ purchaseState, itemId, quantity, purchaseTime, developerPayload);
+ }
+ });
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java b/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
new file mode 100644
index 000000000..48f22e060
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
@@ -0,0 +1,169 @@
+// Copyright 2010 Google Inc. All Rights Reserved.
+
+package com.todoroo.astrid.billing;
+
+
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
+import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
+import com.todoroo.astrid.billing.BillingService.RequestPurchase;
+import com.todoroo.astrid.billing.BillingService.RestoreTransactions;
+import com.todoroo.astrid.utility.Constants;
+
+/**
+ * This class contains the methods that handle responses from Android Market. The
+ * implementation of these methods is specific to a particular application.
+ * The methods in this example update the database and, if the main application
+ * has registered a {@llink PurchaseObserver}, will also update the UI. An
+ * application might also want to forward some responses on to its own server,
+ * and that could be done here (in a background thread) but this example does
+ * not do that.
+ *
+ * You should modify and obfuscate this code before using it.
+ */
+public class ResponseHandler {
+ private static final String TAG = "response-handler"; //$NON-NLS-1$
+
+ /**
+ * This is a static instance of {@link PurchaseObserver} that the
+ * application creates and registers with this class. The PurchaseObserver
+ * is used for updating the UI if the UI is visible.
+ */
+ private static PurchaseObserver sPurchaseObserver;
+
+ /**
+ * Registers an observer that updates the UI.
+ * @param observer the observer to register
+ */
+ public static synchronized void register(PurchaseObserver observer) {
+ sPurchaseObserver = observer;
+ }
+
+ /**
+ * Unregisters a previously registered observer.
+ * @param observer the previously registered observer.
+ */
+ public static synchronized void unregister(PurchaseObserver observer) {
+ sPurchaseObserver = null;
+ }
+
+ /**
+ * Notifies the application of the availability of the MarketBillingService.
+ * This method is called in response to the application calling
+ * {@link BillingService#checkBillingSupported()}.
+ * @param supported true if in-app billing is supported.
+ */
+ public static void checkBillingSupportedResponse(boolean supported, String type) {
+ if (sPurchaseObserver != null) {
+ sPurchaseObserver.onBillingSupported(supported, type);
+ }
+ }
+
+ /**
+ * Starts a new activity for the user to buy an item for sale. This method
+ * forwards the intent on to the PurchaseObserver (if it exists) because
+ * we need to start the activity on the activity stack of the application.
+ *
+ * @param pendingIntent a PendingIntent that we received from Android Market that
+ * will create the new buy page activity
+ * @param intent an intent containing a request id in an extra field that
+ * will be passed to the buy page activity when it is created
+ */
+ public static void buyPageIntentResponse(PendingIntent pendingIntent, Intent intent) {
+ if (sPurchaseObserver == null) {
+ if (Constants.DEBUG) {
+ Log.d(TAG, "UI is not running");
+ }
+ return;
+ }
+ sPurchaseObserver.startBuyPageActivity(pendingIntent, intent);
+ }
+
+ /**
+ * Notifies the application of purchase state changes. The application
+ * can offer an item for sale to the user via
+ * {@link BillingService#requestPurchase(String)}. The BillingService
+ * calls this method after it gets the response. Another way this method
+ * can be called is if the user bought something on another device running
+ * this same app. Then Android Market notifies the other devices that
+ * the user has purchased an item, in which case the BillingService will
+ * also call this method. Finally, this method can be called if the item
+ * was refunded.
+ * @param purchaseState the state of the purchase request (PURCHASED,
+ * CANCELED, or REFUNDED)
+ * @param productId a string identifying a product for sale
+ * @param orderId a string identifying the order
+ * @param purchaseTime the time the product was purchased, in milliseconds
+ * since the epoch (Jan 1, 1970)
+ * @param developerPayload the developer provided "payload" associated with
+ * the order
+ */
+ public static void purchaseResponse(
+ final Context context, final PurchaseState purchaseState, final String productId,
+ final String orderId, final long purchaseTime, final String developerPayload) {
+
+ // Update the database with the purchase state. We shouldn't do that
+ // from the main thread so we do the work in a background thread.
+ // We don't update the UI here. We will update the UI after we update
+ // the database because we need to read and update the current quantity
+ // first.
+ new Thread(new Runnable() {
+ @Override
+ public void run() {
+// PurchaseDatabase db = new PurchaseDatabase(context);
+// int quantity = db.updatePurchase(
+// orderId, productId, purchaseState, purchaseTime, developerPayload);
+// db.close();
+//
+// // This needs to be synchronized because the UI thread can change the
+// // value of sPurchaseObserver.
+// synchronized(ResponseHandler.class) {
+// if (sPurchaseObserver != null) {
+// sPurchaseObserver.postPurchaseStateChange(
+// purchaseState, productId, quantity, purchaseTime, developerPayload);
+// }
+// }
+ }
+ }).start();
+ }
+
+ /**
+ * This is called when we receive a response code from Android Market for a
+ * RequestPurchase request that we made. This is used for reporting various
+ * errors and also for acknowledging that an order was sent successfully to
+ * the server. This is NOT used for any purchase state changes. All
+ * purchase state changes are received in the {@link BillingReceiver} and
+ * are handled in {@link Security#verifyPurchase(String, String)}.
+ * @param context the context
+ * @param request the RequestPurchase request for which we received a
+ * response code
+ * @param responseCode a response code from Market to indicate the state
+ * of the request
+ */
+ public static void responseCodeReceived(Context context, RequestPurchase request,
+ ResponseCode responseCode) {
+ if (sPurchaseObserver != null) {
+ sPurchaseObserver.onRequestPurchaseResponse(request, responseCode);
+ }
+ }
+
+ /**
+ * This is called when we receive a response code from Android Market for a
+ * RestoreTransactions request.
+ * @param context the context
+ * @param request the RestoreTransactions request for which we received a
+ * response code
+ * @param responseCode a response code from Market to indicate the state
+ * of the request
+ */
+ public static void responseCodeReceived(Context context, RestoreTransactions request,
+ ResponseCode responseCode) {
+ if (sPurchaseObserver != null) {
+ sPurchaseObserver.onRestoreTransactionsResponse(request, responseCode);
+ }
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/Security.java b/astrid/src/com/todoroo/astrid/billing/Security.java
new file mode 100644
index 000000000..6c1491f0c
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/Security.java
@@ -0,0 +1,247 @@
+// Copyright 2010 Google Inc. All Rights Reserved.
+
+package com.todoroo.astrid.billing;
+
+import java.security.InvalidKeyException;
+import java.security.KeyFactory;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.SecureRandom;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.HashSet;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.text.TextUtils;
+import android.util.Log;
+
+import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
+import com.todoroo.astrid.utility.Constants;
+
+/**
+ * Security-related methods. For a secure implementation, all of this code
+ * should be implemented on a server that communicates with the
+ * application on the device. For the sake of simplicity and clarity of this
+ * example, this code is included here and is executed on the device. If you
+ * must verify the purchases on the phone, you should obfuscate this code to
+ * make it harder for an attacker to replace the code with stubs that treat all
+ * purchases as verified.
+ */
+@SuppressWarnings("nls")
+public class Security {
+ private static final String TAG = "Security";
+
+ private static final String KEY_FACTORY_ALGORITHM = "RSA";
+ private static final String SIGNATURE_ALGORITHM = "SHA1withRSA";
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ /**
+ * This keeps track of the nonces that we generated and sent to the
+ * server. We need to keep track of these until we get back the purchase
+ * state and send a confirmation message back to Android Market. If we are
+ * killed and lose this list of nonces, it is not fatal. Android Market will
+ * send us a new "notify" message and we will re-generate a new nonce.
+ * This has to be "static" so that the {@link BillingReceiver} can
+ * check if a nonce exists.
+ */
+ private static HashSet sKnownNonces = new HashSet();
+
+ /**
+ * A class to hold the verified purchase information.
+ */
+ public static class VerifiedPurchase {
+ public PurchaseState purchaseState;
+ public String notificationId;
+ public String productId;
+ public String orderId;
+ public long purchaseTime;
+ public String developerPayload;
+
+ public VerifiedPurchase(PurchaseState purchaseState, String notificationId,
+ String productId, String orderId, long purchaseTime, String developerPayload) {
+ this.purchaseState = purchaseState;
+ this.notificationId = notificationId;
+ this.productId = productId;
+ this.orderId = orderId;
+ this.purchaseTime = purchaseTime;
+ this.developerPayload = developerPayload;
+ }
+ }
+
+ /** Generates a nonce (a random number used once). */
+ public static long generateNonce() {
+ long nonce = RANDOM.nextLong();
+ sKnownNonces.add(nonce);
+ return nonce;
+ }
+
+ public static void removeNonce(long nonce) {
+ sKnownNonces.remove(nonce);
+ }
+
+ public static boolean isNonceKnown(long nonce) {
+ return sKnownNonces.contains(nonce);
+ }
+
+ /**
+ * Verifies that the data was signed with the given signature, and returns
+ * the list of verified purchases. The data is in JSON format and contains
+ * a nonce (number used once) that we generated and that was signed
+ * (as part of the whole data string) with a private key. The data also
+ * contains the {@link PurchaseState} and product ID of the purchase.
+ * In the general case, there can be an array of purchase transactions
+ * because there may be delays in processing the purchase on the backend
+ * and then several purchases can be batched together.
+ * @param signedData the signed JSON string (signed, not encrypted)
+ * @param signature the signature for the data, signed with the private key
+ */
+ public static ArrayList verifyPurchase(String signedData, String signature) {
+ if (signedData == null) {
+ Log.e(TAG, "data is null");
+ return null;
+ }
+ if (Constants.DEBUG) {
+ Log.i(TAG, "signedData: " + signedData);
+ }
+ boolean verified = false;
+ if (!TextUtils.isEmpty(signature)) {
+ /**
+ * Compute your public key (that you got from the Android Market publisher site).
+ *
+ * Instead of just storing the entire literal string here embedded in the
+ * program, construct the key at runtime from pieces or
+ * use bit manipulation (for example, XOR with some other string) to hide
+ * the actual key. The key itself is not secret information, but we don't
+ * want to make it easy for an adversary to replace the public key with one
+ * of their own and then fake messages from the server.
+ *
+ * Generally, encryption keys / passwords should only be kept in memory
+ * long enough to perform the operation they need to perform.
+ */
+ String base64EncodedPublicKey = "your public key here";
+ PublicKey key = Security.generatePublicKey(base64EncodedPublicKey);
+ verified = Security.verify(key, signedData, signature);
+ if (!verified) {
+ Log.w(TAG, "signature does not match data.");
+ return null;
+ }
+ }
+
+ JSONObject jObject;
+ JSONArray jTransactionsArray = null;
+ int numTransactions = 0;
+ long nonce = 0L;
+ try {
+ jObject = new JSONObject(signedData);
+
+ // The nonce might be null if the user backed out of the buy page.
+ nonce = jObject.optLong("nonce");
+ jTransactionsArray = jObject.optJSONArray("orders");
+ if (jTransactionsArray != null) {
+ numTransactions = jTransactionsArray.length();
+ }
+ } catch (JSONException e) {
+ return null;
+ }
+
+ if (!Security.isNonceKnown(nonce)) {
+ Log.w(TAG, "Nonce not found: " + nonce);
+ return null;
+ }
+
+ ArrayList purchases = new ArrayList();
+ try {
+ for (int i = 0; i < numTransactions; i++) {
+ JSONObject jElement = jTransactionsArray.getJSONObject(i);
+ int response = jElement.getInt("purchaseState");
+ PurchaseState purchaseState = PurchaseState.valueOf(response);
+ String productId = jElement.getString("productId");
+ String packageName = jElement.getString("packageName");
+ long purchaseTime = jElement.getLong("purchaseTime");
+ String orderId = jElement.optString("orderId", "");
+ String notifyId = null;
+ if (jElement.has("notificationId")) {
+ notifyId = jElement.getString("notificationId");
+ }
+ String developerPayload = jElement.optString("developerPayload", null);
+
+ // If the purchase state is PURCHASED, then we require a
+ // verified nonce.
+ if (purchaseState == PurchaseState.PURCHASED && !verified) {
+ continue;
+ }
+ purchases.add(new VerifiedPurchase(purchaseState, notifyId, productId,
+ orderId, purchaseTime, developerPayload));
+ }
+ } catch (JSONException e) {
+ Log.e(TAG, "JSON exception: ", e);
+ return null;
+ }
+ removeNonce(nonce);
+ return purchases;
+ }
+
+ /**
+ * Generates a PublicKey instance from a string containing the
+ * Base64-encoded public key.
+ *
+ * @param encodedPublicKey Base64-encoded public key
+ * @throws IllegalArgumentException if encodedPublicKey is invalid
+ */
+ public static PublicKey generatePublicKey(String encodedPublicKey) {
+ try {
+ byte[] decodedKey = Base64.decode(encodedPublicKey);
+ KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM);
+ return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey));
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ } catch (InvalidKeySpecException e) {
+ Log.e(TAG, "Invalid key specification.");
+ throw new IllegalArgumentException(e);
+ } catch (Base64DecoderException e) {
+ Log.e(TAG, "Base64 decoding failed.");
+ throw new IllegalArgumentException(e);
+ }
+ }
+
+ /**
+ * Verifies that the signature from the server matches the computed
+ * signature on the data. Returns true if the data is correctly signed.
+ *
+ * @param publicKey public key associated with the developer account
+ * @param signedData signed data from server
+ * @param signature server signature
+ * @return true if the data and signature match
+ */
+ public static boolean verify(PublicKey publicKey, String signedData, String signature) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "signature: " + signature);
+ }
+ Signature sig;
+ try {
+ sig = Signature.getInstance(SIGNATURE_ALGORITHM);
+ sig.initVerify(publicKey);
+ sig.update(signedData.getBytes());
+ if (!sig.verify(Base64.decode(signature))) {
+ Log.e(TAG, "Signature verification failed.");
+ return false;
+ }
+ return true;
+ } catch (NoSuchAlgorithmException e) {
+ Log.e(TAG, "NoSuchAlgorithmException.");
+ } catch (InvalidKeyException e) {
+ Log.e(TAG, "Invalid key specification.");
+ } catch (SignatureException e) {
+ Log.e(TAG, "Signature exception.");
+ } catch (Base64DecoderException e) {
+ Log.e(TAG, "Base64 decoding failed.");
+ }
+ return false;
+ }
+}
From b8d5fedf84d35b61c0511f2027a27ef8effb121c Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 15:02:06 -0700
Subject: [PATCH 02/34] Skeleton of a billing activity
---
astrid/res/layout/billing_activity.xml | 20 ++++
astrid/res/values/strings-premium.xml | 8 ++
.../astrid/billing/BillingActivity.java | 99 +++++++++++++++++++
.../astrid/billing/BillingConstants.java | 4 +
4 files changed, 131 insertions(+)
create mode 100644 astrid/res/layout/billing_activity.xml
create mode 100644 astrid/src/com/todoroo/astrid/billing/BillingActivity.java
diff --git a/astrid/res/layout/billing_activity.xml b/astrid/res/layout/billing_activity.xml
new file mode 100644
index 000000000..f1c9ddb86
--- /dev/null
+++ b/astrid/res/layout/billing_activity.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/astrid/res/values/strings-premium.xml b/astrid/res/values/strings-premium.xml
index b27baab61..89f15f1c4 100644
--- a/astrid/res/values/strings-premium.xml
+++ b/astrid/res/values/strings-premium.xml
@@ -44,4 +44,12 @@
Error copying file for attachmentError downloading fileSorry, the system does not yet support this type of file
+
+
+ Subscriptions not supported
+ Sorry! The Market billing
+ service on this device does not support subscriptions at this time.
+ Learn more
+ http://market.android.com/support/bin/answer.py?answer=1050566&hl=%lang%&dl=%region%
+
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
new file mode 100644
index 000000000..7b69811e3
--- /dev/null
+++ b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
@@ -0,0 +1,99 @@
+package com.todoroo.astrid.billing;
+
+import java.util.Locale;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.View;
+import android.view.View.OnClickListener;
+import android.widget.Button;
+
+import com.timsu.astrid.R;
+
+public class BillingActivity extends Activity {
+
+ private BillingService billingService;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.billing_activity);
+
+ setupButtons();
+
+ billingService = new BillingService();
+ billingService.setContext(this);
+
+ if (!billingService.checkBillingSupported(BillingConstants.ITEM_TYPE_SUBSCRIPTION)) {
+ showSubscriptionsNotSupported();
+ }
+ }
+
+ private void setupButtons() {
+ Button buyMonth = (Button) findViewById(R.id.buy_month);
+ Button buyYear = (Button) findViewById(R.id.buy_year);
+
+ //TODO: Figure out if we need a payload for any reason
+
+ buyMonth.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (!billingService.requestPurchase(BillingConstants.PRODUCT_ID_MONTHLY,
+ BillingConstants.ITEM_TYPE_SUBSCRIPTION, null)) {
+ showSubscriptionsNotSupported();
+ }
+ }
+ });
+
+ buyYear.setOnClickListener(new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ if (!billingService.requestPurchase(BillingConstants.PRODUCT_ID_YEARLY,
+ BillingConstants.ITEM_TYPE_SUBSCRIPTION, null)) {
+ showSubscriptionsNotSupported();
+ }
+ }
+ });
+ }
+
+ private void showSubscriptionsNotSupported() {
+ String helpUrl = replaceLanguageAndRegion(getString(R.string.subscriptions_help_url));
+ final Uri helpUri = Uri.parse(helpUrl);
+
+ new AlertDialog.Builder(this)
+ .setTitle(R.string.subscriptions_not_supported)
+ .setMessage(R.string.subscriptions_not_supported_message)
+ .setCancelable(false)
+ .setPositiveButton(R.string.DLG_ok, null)
+ .setNegativeButton(R.string.subscriptions_learn_more, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, helpUri);
+ startActivity(intent);
+ }
+ }).create().show();
+ }
+
+ /**
+ * Replaces the language and/or country of the device into the given string.
+ * The pattern "%lang%" will be replaced by the device's language code and
+ * the pattern "%region%" will be replaced with the device's country code.
+ *
+ * @param str the string to replace the language/country within
+ * @return a string containing the local language and region codes
+ */
+ @SuppressWarnings("nls")
+ private String replaceLanguageAndRegion(String str) {
+ // Substitute language and or region if present in string
+ if (str.contains("%lang%") || str.contains("%region%")) {
+ Locale locale = Locale.getDefault();
+ str = str.replace("%lang%", locale.getLanguage().toLowerCase());
+ str = str.replace("%region%", locale.getCountry().toLowerCase());
+ }
+ return str;
+ }
+}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
index 06c0c3b3e..9be884899 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
@@ -53,6 +53,10 @@ public class BillingConstants {
public static final String ITEM_TYPE_SUBSCRIPTION = "subs";
+
+ public static final String PRODUCT_ID_MONTHLY = "premium_monthly";
+ public static final String PRODUCT_ID_YEARLY = "premium_yearly";
+
// The response codes for a request, defined by Android Market.
public enum ResponseCode {
RESULT_OK,
From a8f74da42e42d3223c470541bb93a6b5b3de0354 Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 15:29:08 -0700
Subject: [PATCH 03/34] More code and refactoring in the billing activity
---
astrid/res/values/strings-premium.xml | 16 +-
.../astrid/billing/BillingActivity.java | 197 ++++++++++++++++--
.../astrid/billing/BillingReceiver.java | 81 ++++++-
.../astrid/billing/PurchaseObserver.java | 2 +-
4 files changed, 269 insertions(+), 27 deletions(-)
diff --git a/astrid/res/values/strings-premium.xml b/astrid/res/values/strings-premium.xml
index 89f15f1c4..a4f030a74 100644
--- a/astrid/res/values/strings-premium.xml
+++ b/astrid/res/values/strings-premium.xml
@@ -46,9 +46,21 @@
Sorry, the system does not yet support this type of file
- Subscriptions not supported
- Sorry! The Market billing
+ Can\'t make purchases
+ The Market billing
+ service is not available at this time. You can continue to use this app but you
+ won\'t be able to make purchases.
+ Can\'t purchase subscriptions
+ The Market billing
service on this device does not support subscriptions at this time.
+ Can\'t connect to Market
+ This app cannot connect to Market.
+ Your version of Market may be out of date.
+ You can continue to use this app but you
+ won\'t be able to make purchases.
+
+ Restoring transactions
+
Learn morehttp://market.android.com/support/bin/answer.py?answer=1050566&hl=%lang%&dl=%region%
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
index 7b69811e3..c22b1bd7e 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
@@ -4,19 +4,40 @@ import java.util.Locale;
import android.app.Activity;
import android.app.AlertDialog;
+import android.app.Dialog;
import android.content.DialogInterface;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
+import android.os.Handler;
+import android.util.Log;
import android.view.View;
import android.view.View.OnClickListener;
import android.widget.Button;
+import android.widget.Toast;
import com.timsu.astrid.R;
+import com.todoroo.andlib.utility.Preferences;
+import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
+import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
+import com.todoroo.astrid.billing.BillingService.RequestPurchase;
+import com.todoroo.astrid.billing.BillingService.RestoreTransactions;
+import com.todoroo.astrid.utility.Constants;
public class BillingActivity extends Activity {
+ private static final int DIALOG_CANNOT_CONNECT_ID = 1;
+ private static final int DIALOG_BILLING_NOT_SUPPORTED_ID = 2;
+ private static final int DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID = 3;
+
+ private static final String TRANSACTIONS_INITIALIZED = "premium_transactions_initialized"; //$NON-NLS-1$
+
+ private Handler handler;
private BillingService billingService;
+ private AstridPurchaseObserver purchaseObserver;
+ private Button buyMonth;
+ private Button buyYear;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -25,17 +46,21 @@ public class BillingActivity extends Activity {
setupButtons();
+ handler = new Handler();
billingService = new BillingService();
billingService.setContext(this);
+ purchaseObserver = new AstridPurchaseObserver(handler);
+
+ ResponseHandler.register(purchaseObserver);
if (!billingService.checkBillingSupported(BillingConstants.ITEM_TYPE_SUBSCRIPTION)) {
- showSubscriptionsNotSupported();
+ showDialog(DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID);
}
}
private void setupButtons() {
- Button buyMonth = (Button) findViewById(R.id.buy_month);
- Button buyYear = (Button) findViewById(R.id.buy_year);
+ buyMonth = (Button) findViewById(R.id.buy_month);
+ buyYear = (Button) findViewById(R.id.buy_year);
//TODO: Figure out if we need a payload for any reason
@@ -44,7 +69,7 @@ public class BillingActivity extends Activity {
public void onClick(View v) {
if (!billingService.requestPurchase(BillingConstants.PRODUCT_ID_MONTHLY,
BillingConstants.ITEM_TYPE_SUBSCRIPTION, null)) {
- showSubscriptionsNotSupported();
+ showDialog(DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID);
}
}
});
@@ -54,30 +79,12 @@ public class BillingActivity extends Activity {
public void onClick(View v) {
if (!billingService.requestPurchase(BillingConstants.PRODUCT_ID_YEARLY,
BillingConstants.ITEM_TYPE_SUBSCRIPTION, null)) {
- showSubscriptionsNotSupported();
+ showDialog(DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID);
}
}
});
}
- private void showSubscriptionsNotSupported() {
- String helpUrl = replaceLanguageAndRegion(getString(R.string.subscriptions_help_url));
- final Uri helpUri = Uri.parse(helpUrl);
-
- new AlertDialog.Builder(this)
- .setTitle(R.string.subscriptions_not_supported)
- .setMessage(R.string.subscriptions_not_supported_message)
- .setCancelable(false)
- .setPositiveButton(R.string.DLG_ok, null)
- .setNegativeButton(R.string.subscriptions_learn_more, new DialogInterface.OnClickListener() {
- @Override
- public void onClick(DialogInterface dialog, int which) {
- Intent intent = new Intent(Intent.ACTION_VIEW, helpUri);
- startActivity(intent);
- }
- }).create().show();
- }
-
/**
* Replaces the language and/or country of the device into the given string.
* The pattern "%lang%" will be replaced by the device's language code and
@@ -96,4 +103,148 @@ public class BillingActivity extends Activity {
}
return str;
}
+
+ @Override
+ protected Dialog onCreateDialog(int id) {
+ switch (id) {
+ case DIALOG_CANNOT_CONNECT_ID:
+ return createDialog(R.string.cannot_connect_title,
+ R.string.cannot_connect_message);
+ case DIALOG_BILLING_NOT_SUPPORTED_ID:
+ return createDialog(R.string.billing_not_supported_title,
+ R.string.billing_not_supported_message);
+ case DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID:
+ return createDialog(R.string.subscriptions_not_supported_title,
+ R.string.subscriptions_not_supported_message);
+ default:
+ return null;
+ }
+ }
+
+ private Dialog createDialog(int titleId, int messageId) {
+ String helpUrl = replaceLanguageAndRegion(getString(R.string.subscriptions_help_url));
+ if (Constants.DEBUG) {
+ Log.i("billing-activity-url", helpUrl); //$NON-NLS-1$
+ }
+ final Uri helpUri = Uri.parse(helpUrl);
+
+ AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(titleId)
+ .setIcon(android.R.drawable.stat_sys_warning)
+ .setMessage(messageId)
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok, null)
+ .setNegativeButton(R.string.subscriptions_learn_more, new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog, int which) {
+ Intent intent = new Intent(Intent.ACTION_VIEW, helpUri);
+ startActivity(intent);
+ }
+ });
+ return builder.create();
+ }
+
+ private void restoreTransactions() {
+ boolean initialized = Preferences.getBoolean(TRANSACTIONS_INITIALIZED, false);
+ if (!initialized) {
+ billingService.restoreTransactions();
+ Toast.makeText(this, R.string.restoring_transactions, Toast.LENGTH_LONG).show();
+ }
+ }
+
+ /**
+ * A {@link PurchaseObserver} is used to get callbacks when Android Market sends
+ * messages to this application so that we can update the UI.
+ */
+ @SuppressWarnings("nls")
+ private class AstridPurchaseObserver extends PurchaseObserver {
+ public AstridPurchaseObserver(Handler handler) {
+ super(BillingActivity.this, handler);
+ }
+
+ @Override
+ public void onBillingSupported(boolean supported, String type) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "supported: " + supported);
+ }
+ if (type != null && type.equals(BillingConstants.ITEM_TYPE_SUBSCRIPTION)) {
+ if (supported) {
+ restoreTransactions();
+ buyMonth.setEnabled(true);
+ buyYear.setEnabled(true);
+ } else {
+ showDialog(DIALOG_BILLING_NOT_SUPPORTED_ID);
+ }
+ } else {
+ showDialog(DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID);
+ }
+ }
+
+ @Override
+ public void onPurchaseStateChange(PurchaseState purchaseState, String itemId,
+ int quantity, long purchaseTime, String developerPayload) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "onPurchaseStateChange() itemId: " + itemId + " " + purchaseState);
+ }
+
+ if (developerPayload == null) {
+ //
+ } else {
+ //
+ }
+
+ if (purchaseState == PurchaseState.PURCHASED) {
+// mOwnedItems.add(itemId);
+//
+// // If this is a subscription, then enable the "Edit
+// // Subscriptions" button.
+// for (CatalogEntry e : CATALOG) {
+// if (e.sku.equals(itemId) &&
+// e.managed.equals(Managed.SUBSCRIPTION)) {
+// mEditSubscriptionsButton.setVisibility(View.VISIBLE);
+// }
+// }
+ }
+// mCatalogAdapter.setOwnedItems(mOwnedItems);
+// mOwnedItemsCursor.requery();
+ }
+
+ @Override
+ public void onRequestPurchaseResponse(RequestPurchase request,
+ ResponseCode responseCode) {
+ if (Constants.DEBUG) {
+ Log.d(TAG, request.mProductId + ": " + responseCode);
+ }
+ if (responseCode == ResponseCode.RESULT_OK) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "purchase was successfully sent to server");
+ }
+ } else if (responseCode == ResponseCode.RESULT_USER_CANCELED) {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "user canceled purchase");
+ }
+ } else {
+ if (Constants.DEBUG) {
+ Log.i(TAG, "purchase failed");
+ }
+ }
+ }
+
+ @Override
+ public void onRestoreTransactionsResponse(RestoreTransactions request,
+ ResponseCode responseCode) {
+ if (responseCode == ResponseCode.RESULT_OK) {
+ if (Constants.DEBUG) {
+ Log.d(TAG, "completed RestoreTransactions request");
+ }
+ // Update the shared preferences so that we don't perform
+ // a RestoreTransactions again.
+ Preferences.setBoolean(TRANSACTIONS_INITIALIZED, true);
+ } else {
+ if (Constants.DEBUG) {
+ Log.d(TAG, "RestoreTransactions error: " + responseCode);
+ }
+ }
+ }
+ }
}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java b/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
index 1708989da..fec87f70c 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingReceiver.java
@@ -3,13 +3,92 @@ package com.todoroo.astrid.billing;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
+import android.util.Log;
+
+import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
+import com.todoroo.astrid.utility.Constants;
public class BillingReceiver extends BroadcastReceiver {
+ private static final String TAG = "billing-receiver"; //$NON-NLS-1$
+ /**
+ * This is the entry point for all asynchronous messages sent from Android Market to
+ * the application. This method forwards the messages on to the
+ * {@link BillingService}, which handles the communication back to Android Market.
+ * The {@link BillingService} also reports state changes back to the application through
+ * the {@link ResponseHandler}.
+ */
@Override
public void onReceive(Context context, Intent intent) {
- // TODO Auto-generated method stub
+ String action = intent.getAction();
+ if (BillingConstants.ACTION_PURCHASE_STATE_CHANGED.equals(action)) {
+ String signedData = intent.getStringExtra(BillingConstants.INAPP_SIGNED_DATA);
+ String signature = intent.getStringExtra(BillingConstants.INAPP_SIGNATURE);
+ purchaseStateChanged(context, signedData, signature);
+ } else if (BillingConstants.ACTION_NOTIFY.equals(action)) {
+ String notifyId = intent.getStringExtra(BillingConstants.NOTIFICATION_ID);
+ if (Constants.DEBUG) {
+ Log.i(TAG, "notifyId: " + notifyId); //$NON-NLS-1$
+ }
+ notify(context, notifyId);
+ } else if (BillingConstants.ACTION_RESPONSE_CODE.equals(action)) {
+ long requestId = intent.getLongExtra(BillingConstants.INAPP_REQUEST_ID, -1);
+ int responseCodeIndex = intent.getIntExtra(BillingConstants.INAPP_RESPONSE_CODE,
+ ResponseCode.RESULT_ERROR.ordinal());
+ checkResponseCode(context, requestId, responseCodeIndex);
+ } else {
+ Log.w(TAG, "unexpected action: " + action); //$NON-NLS-1$
+ }
+ }
+ /**
+ * This is called when Android Market sends information about a purchase state
+ * change. The signedData parameter is a plaintext JSON string that is
+ * signed by the server with the developer's private key. The signature
+ * for the signed data is passed in the signature parameter.
+ * @param context the context
+ * @param signedData the (unencrypted) JSON string
+ * @param signature the signature for the signedData
+ */
+ private void purchaseStateChanged(Context context, String signedData, String signature) {
+ Intent intent = new Intent(BillingConstants.ACTION_PURCHASE_STATE_CHANGED);
+ intent.setClass(context, BillingService.class);
+ intent.putExtra(BillingConstants.INAPP_SIGNED_DATA, signedData);
+ intent.putExtra(BillingConstants.INAPP_SIGNATURE, signature);
+ context.startService(intent);
}
+ /**
+ * This is called when Android Market sends a "notify" message indicating that transaction
+ * information is available. The request includes a nonce (random number used once) that
+ * we generate and Android Market signs and sends back to us with the purchase state and
+ * other transaction details. This BroadcastReceiver cannot bind to the
+ * MarketBillingService directly so it starts the {@link BillingService}, which does the
+ * actual work of sending the message.
+ *
+ * @param context the context
+ * @param notifyId the notification ID
+ */
+ private void notify(Context context, String notifyId) {
+ Intent intent = new Intent(BillingConstants.ACTION_GET_PURCHASE_INFORMATION);
+ intent.setClass(context, BillingService.class);
+ intent.putExtra(BillingConstants.NOTIFICATION_ID, notifyId);
+ context.startService(intent);
+ }
+
+ /**
+ * This is called when Android Market sends a server response code. The BillingService can
+ * then report the status of the response if desired.
+ *
+ * @param context the context
+ * @param requestId the request ID that corresponds to a previous request
+ * @param responseCodeIndex the ResponseCode ordinal value for the request
+ */
+ private void checkResponseCode(Context context, long requestId, int responseCodeIndex) {
+ Intent intent = new Intent(BillingConstants.ACTION_RESPONSE_CODE);
+ intent.setClass(context, BillingService.class);
+ intent.putExtra(BillingConstants.INAPP_REQUEST_ID, requestId);
+ intent.putExtra(BillingConstants.INAPP_RESPONSE_CODE, responseCodeIndex);
+ context.startService(intent);
+ }
}
diff --git a/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
index 2e11d1809..316aba62c 100644
--- a/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
+++ b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
@@ -27,7 +27,7 @@ import com.todoroo.astrid.billing.BillingService.RestoreTransactions;
* are used to update the UI.
*/
public abstract class PurchaseObserver {
- private static final String TAG = "purchase-observer"; //$NON-NLS-1$
+ protected static final String TAG = "purchase-observer"; //$NON-NLS-1$
private final Activity mActivity;
private final Handler mHandler;
private Method mStartIntentSender;
From 2d20f5fd3e5c3e2350428261bc297bcc578b1dbf Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 17:33:23 -0700
Subject: [PATCH 04/34] Add a EXPIRED constant to the PurchaseState enum
---
.../astrid/billing/BillingActivity.java | 42 +++++++++++--------
.../astrid/billing/BillingConstants.java | 6 +--
2 files changed, 27 insertions(+), 21 deletions(-)
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
index c22b1bd7e..806c81f82 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
@@ -17,7 +17,10 @@ import android.widget.Button;
import android.widget.Toast;
import com.timsu.astrid.R;
+import com.todoroo.andlib.service.Autowired;
+import com.todoroo.andlib.service.DependencyInjectionService;
import com.todoroo.andlib.utility.Preferences;
+import com.todoroo.astrid.actfm.sync.ActFmPreferenceService;
import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
import com.todoroo.astrid.billing.BillingService.RequestPurchase;
@@ -38,9 +41,12 @@ public class BillingActivity extends Activity {
private Button buyMonth;
private Button buyYear;
+ @Autowired private ActFmPreferenceService actFmPreferenceService;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
+ DependencyInjectionService.getInstance().inject(this);
super.onCreate(savedInstanceState);
setContentView(R.layout.billing_activity);
@@ -53,7 +59,11 @@ public class BillingActivity extends Activity {
ResponseHandler.register(purchaseObserver);
- if (!billingService.checkBillingSupported(BillingConstants.ITEM_TYPE_SUBSCRIPTION)) {
+ if (!actFmPreferenceService.isLoggedIn()) {
+ // Handle
+ } else if (ActFmPreferenceService.isPremiumUser()) {
+ // Handle
+ } else if (!billingService.checkBillingSupported(BillingConstants.ITEM_TYPE_SUBSCRIPTION)) {
showDialog(DIALOG_SUBSCRIPTIONS_NOT_SUPPORTED_ID);
}
}
@@ -62,6 +72,9 @@ public class BillingActivity extends Activity {
buyMonth = (Button) findViewById(R.id.buy_month);
buyYear = (Button) findViewById(R.id.buy_year);
+ buyMonth.setEnabled(false);
+ buyYear.setEnabled(false);
+
//TODO: Figure out if we need a payload for any reason
buyMonth.setOnClickListener(new OnClickListener() {
@@ -187,26 +200,19 @@ public class BillingActivity extends Activity {
Log.i(TAG, "onPurchaseStateChange() itemId: " + itemId + " " + purchaseState);
}
- if (developerPayload == null) {
- //
- } else {
- //
- }
+// if (developerPayload == null) {
+// //
+// } else {
+// //
+// }
if (purchaseState == PurchaseState.PURCHASED) {
-// mOwnedItems.add(itemId);
-//
-// // If this is a subscription, then enable the "Edit
-// // Subscriptions" button.
-// for (CatalogEntry e : CATALOG) {
-// if (e.sku.equals(itemId) &&
-// e.managed.equals(Managed.SUBSCRIPTION)) {
-// mEditSubscriptionsButton.setVisibility(View.VISIBLE);
-// }
-// }
+ // Success
+ // Report premium activation to server
+ } else if (purchaseState == PurchaseState.REFUNDED || purchaseState == PurchaseState.EXPIRED) {
+ // Subscription ended
+ // Report premium deactivation to server
}
-// mCatalogAdapter.setOwnedItems(mOwnedItems);
-// mOwnedItemsCursor.requery();
}
@Override
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
index 9be884899..212107b9d 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
@@ -53,7 +53,6 @@ public class BillingConstants {
public static final String ITEM_TYPE_SUBSCRIPTION = "subs";
-
public static final String PRODUCT_ID_MONTHLY = "premium_monthly";
public static final String PRODUCT_ID_YEARLY = "premium_yearly";
@@ -81,8 +80,9 @@ public class BillingConstants {
public enum PurchaseState {
// Responses to requestPurchase or restoreTransactions.
PURCHASED, // User was charged for the order.
- CANCELED, // The charge failed on the server.
- REFUNDED; // User received a refund for the order.
+ CANCELED, // The charge failed on the server. (NOT THE SAME AS CANCELING A SUBSCRIPTION)
+ REFUNDED, // User received a refund for the order.
+ EXPIRED; // Subscription expired due to non-payment or cancellation
// Converts from an ordinal value to the PurchaseState
public static PurchaseState valueOf(int index) {
From 754081e5198d762d7fa475a420b09aea62e78b7d Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 17:35:07 -0700
Subject: [PATCH 05/34] Code cleanup
---
.../astrid/billing/BillingConstants.java | 24 +++++++------------
1 file changed, 9 insertions(+), 15 deletions(-)
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
index 212107b9d..b0b2e263e 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingConstants.java
@@ -5,25 +5,19 @@ package com.todoroo.astrid.billing;
public class BillingConstants {
/** This is the action we use to bind to the MarketBillingService. */
- public static final String MARKET_BILLING_SERVICE_ACTION =
- "com.android.vending.billing.MarketBillingService.BIND";
+ public static final String MARKET_BILLING_SERVICE_ACTION = "com.android.vending.billing.MarketBillingService.BIND";
// Intent actions that we send from the BillingReceiver to the
// BillingService. Defined by this application.
- public static final String ACTION_CONFIRM_NOTIFICATION =
- "com.example.subscriptions.CONFIRM_NOTIFICATION";
- public static final String ACTION_GET_PURCHASE_INFORMATION =
- "com.example.subscriptions.GET_PURCHASE_INFORMATION";
- public static final String ACTION_RESTORE_TRANSACTIONS =
- "com.example.subscriptions.RESTORE_TRANSACTIONS";
+ public static final String ACTION_CONFIRM_NOTIFICATION = "com.example.subscriptions.CONFIRM_NOTIFICATION";
+ public static final String ACTION_GET_PURCHASE_INFORMATION = "com.example.subscriptions.GET_PURCHASE_INFORMATION";
+ public static final String ACTION_RESTORE_TRANSACTIONS = "com.example.subscriptions.RESTORE_TRANSACTIONS";
// Intent actions that we receive in the BillingReceiver from Market.
// These are defined by Market and cannot be changed.
public static final String ACTION_NOTIFY = "com.android.vending.billing.IN_APP_NOTIFY";
- public static final String ACTION_RESPONSE_CODE =
- "com.android.vending.billing.RESPONSE_CODE";
- public static final String ACTION_PURCHASE_STATE_CHANGED =
- "com.android.vending.billing.PURCHASE_STATE_CHANGED";
+ public static final String ACTION_RESPONSE_CODE = "com.android.vending.billing.RESPONSE_CODE";
+ public static final String ACTION_PURCHASE_STATE_CHANGED = "com.android.vending.billing.PURCHASE_STATE_CHANGED";
// These are the names of the extras that are passed in an intent from
// Market to this application and cannot be changed.
@@ -56,7 +50,7 @@ public class BillingConstants {
public static final String PRODUCT_ID_MONTHLY = "premium_monthly";
public static final String PRODUCT_ID_YEARLY = "premium_yearly";
- // The response codes for a request, defined by Android Market.
+ // The response codes for a request, defined by Android Market.
public enum ResponseCode {
RESULT_OK,
RESULT_USER_CANCELED,
@@ -81,8 +75,8 @@ public class BillingConstants {
// Responses to requestPurchase or restoreTransactions.
PURCHASED, // User was charged for the order.
CANCELED, // The charge failed on the server. (NOT THE SAME AS CANCELING A SUBSCRIPTION)
- REFUNDED, // User received a refund for the order.
- EXPIRED; // Subscription expired due to non-payment or cancellation
+ REFUNDED, // User received a refund for the order.
+ EXPIRED; // Subscription expired due to non-payment or cancellation
// Converts from an ordinal value to the PurchaseState
public static PurchaseState valueOf(int index) {
From 8147986fb1171dbeb32a665df59e325b53240780 Mon Sep 17 00:00:00 2001
From: Sam Bosley
Date: Thu, 12 Jul 2012 17:49:41 -0700
Subject: [PATCH 06/34] Add billing activity to manifest
---
astrid/AndroidManifest.xml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/astrid/AndroidManifest.xml b/astrid/AndroidManifest.xml
index 9806b3cb1..e5395554c 100644
--- a/astrid/AndroidManifest.xml
+++ b/astrid/AndroidManifest.xml
@@ -521,6 +521,8 @@
+
+
Date: Thu, 12 Jul 2012 18:04:19 -0700
Subject: [PATCH 07/34] Pass the purchase token through the various layers of
the billing system
---
.../astrid/billing/BillingActivity.java | 2 +-
.../astrid/billing/BillingService.java | 2 +-
.../astrid/billing/PurchaseObserver.java | 6 +++---
.../astrid/billing/ResponseHandler.java | 20 +++++++++----------
.../com/todoroo/astrid/billing/Security.java | 7 +++++--
5 files changed, 20 insertions(+), 17 deletions(-)
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
index 806c81f82..276163e68 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingActivity.java
@@ -195,7 +195,7 @@ public class BillingActivity extends Activity {
@Override
public void onPurchaseStateChange(PurchaseState purchaseState, String itemId,
- int quantity, long purchaseTime, String developerPayload) {
+ int quantity, long purchaseTime, String developerPayload, String purchaseToken) {
if (Constants.DEBUG) {
Log.i(TAG, "onPurchaseStateChange() itemId: " + itemId + " " + purchaseState);
}
diff --git a/astrid/src/com/todoroo/astrid/billing/BillingService.java b/astrid/src/com/todoroo/astrid/billing/BillingService.java
index 207f79ae4..a2a5b3c6b 100644
--- a/astrid/src/com/todoroo/astrid/billing/BillingService.java
+++ b/astrid/src/com/todoroo/astrid/billing/BillingService.java
@@ -551,7 +551,7 @@ public class BillingService extends Service implements ServiceConnection {
notifyList.add(vp.notificationId);
}
ResponseHandler.purchaseResponse(this, vp.purchaseState, vp.productId,
- vp.orderId, vp.purchaseTime, vp.developerPayload);
+ vp.orderId, vp.purchaseTime, vp.developerPayload, vp.purchaseToken);
}
if (!notifyList.isEmpty()) {
String[] notifyIds = notifyList.toArray(new String[notifyList.size()]);
diff --git a/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
index 316aba62c..ac79e100e 100644
--- a/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
+++ b/astrid/src/com/todoroo/astrid/billing/PurchaseObserver.java
@@ -66,7 +66,7 @@ public abstract class PurchaseObserver {
* milliseconds since the epoch (Jan 1, 1970)
*/
public abstract void onPurchaseStateChange(PurchaseState purchaseState,
- String itemId, int quantity, long purchaseTime, String developerPayload);
+ String itemId, int quantity, long purchaseTime, String developerPayload, String purchaseToken);
/**
* This is called when we receive a response code from Market for a
@@ -150,12 +150,12 @@ public abstract class PurchaseObserver {
* @param quantity the quantity of items in this purchase
*/
void postPurchaseStateChange(final PurchaseState purchaseState, final String itemId,
- final int quantity, final long purchaseTime, final String developerPayload) {
+ final int quantity, final long purchaseTime, final String developerPayload, final String purchaseToken) {
mHandler.post(new Runnable() {
@Override
public void run() {
onPurchaseStateChange(
- purchaseState, itemId, quantity, purchaseTime, developerPayload);
+ purchaseState, itemId, quantity, purchaseTime, developerPayload, purchaseToken);
}
});
}
diff --git a/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java b/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
index 48f22e060..5d346d57e 100644
--- a/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
+++ b/astrid/src/com/todoroo/astrid/billing/ResponseHandler.java
@@ -104,7 +104,7 @@ public class ResponseHandler {
*/
public static void purchaseResponse(
final Context context, final PurchaseState purchaseState, final String productId,
- final String orderId, final long purchaseTime, final String developerPayload) {
+ final String orderId, final long purchaseTime, final String developerPayload, final String purchaseToken) {
// Update the database with the purchase state. We shouldn't do that
// from the main thread so we do the work in a background thread.
@@ -118,15 +118,15 @@ public class ResponseHandler {
// int quantity = db.updatePurchase(
// orderId, productId, purchaseState, purchaseTime, developerPayload);
// db.close();
-//
-// // This needs to be synchronized because the UI thread can change the
-// // value of sPurchaseObserver.
-// synchronized(ResponseHandler.class) {
-// if (sPurchaseObserver != null) {
-// sPurchaseObserver.postPurchaseStateChange(
-// purchaseState, productId, quantity, purchaseTime, developerPayload);
-// }
-// }
+
+ // This needs to be synchronized because the UI thread can change the
+ // value of sPurchaseObserver.
+ synchronized(ResponseHandler.class) {
+ if (sPurchaseObserver != null) {
+ sPurchaseObserver.postPurchaseStateChange(
+ purchaseState, productId, 1, purchaseTime, developerPayload, purchaseToken);
+ }
+ }
}
}).start();
}
diff --git a/astrid/src/com/todoroo/astrid/billing/Security.java b/astrid/src/com/todoroo/astrid/billing/Security.java
index 6c1491f0c..79577440f 100644
--- a/astrid/src/com/todoroo/astrid/billing/Security.java
+++ b/astrid/src/com/todoroo/astrid/billing/Security.java
@@ -62,15 +62,17 @@ public class Security {
public String orderId;
public long purchaseTime;
public String developerPayload;
+ public String purchaseToken;
public VerifiedPurchase(PurchaseState purchaseState, String notificationId,
- String productId, String orderId, long purchaseTime, String developerPayload) {
+ String productId, String orderId, long purchaseTime, String developerPayload, String purchaseToken) {
this.purchaseState = purchaseState;
this.notificationId = notificationId;
this.productId = productId;
this.orderId = orderId;
this.purchaseTime = purchaseTime;
this.developerPayload = developerPayload;
+ this.purchaseToken = purchaseToken;
}
}
@@ -169,6 +171,7 @@ public class Security {
if (jElement.has("notificationId")) {
notifyId = jElement.getString("notificationId");
}
+ String purchaseToken = jElement.optString("purchaseToken");
String developerPayload = jElement.optString("developerPayload", null);
// If the purchase state is PURCHASED, then we require a
@@ -177,7 +180,7 @@ public class Security {
continue;
}
purchases.add(new VerifiedPurchase(purchaseState, notifyId, productId,
- orderId, purchaseTime, developerPayload));
+ orderId, purchaseTime, developerPayload, purchaseToken));
}
} catch (JSONException e) {
Log.e(TAG, "JSON exception: ", e);
From ba2f828821a816855a0d6d3108c6f3ce476afc9a Mon Sep 17 00:00:00 2001
From: Jimmy Scott
Date: Thu, 12 Jul 2012 18:44:47 -0700
Subject: [PATCH 08/34] Billing xml with relevent strings created
---
astrid/res/drawable/bluebutton.png | Bin 0 -> 2466 bytes
astrid/res/drawable/icn_premium_checkmark.png | Bin 0 -> 249 bytes
astrid/res/drawable/post_it.png | Bin 0 -> 57205 bytes
astrid/res/layout/billing_activity.xml | 183 ++++++++++++++++++
astrid/res/values/colors.xml | 1 +
astrid/res/values/strings-premium.xml | 10 +
6 files changed, 194 insertions(+)
create mode 100644 astrid/res/drawable/bluebutton.png
create mode 100644 astrid/res/drawable/icn_premium_checkmark.png
create mode 100644 astrid/res/drawable/post_it.png
create mode 100644 astrid/res/layout/billing_activity.xml
diff --git a/astrid/res/drawable/bluebutton.png b/astrid/res/drawable/bluebutton.png
new file mode 100644
index 0000000000000000000000000000000000000000..f92b0df0031260c4ad5b827bb7adb44db7c3a770
GIT binary patch
literal 2466
zcmV;T30?MyP)toVZZYgU$d2UQD
zKP$nqERVO6P|>5pWvH>Fn#x((-jT$5OKWpdQtNFR3UI(U_r%%guH?iW7s);fzY3f6
z&l{WT_ZyGacW(UV=Rp!I?nRBI*HkV@s9fkY73VHVsGOmp00+(?=_zk%Ym1T`MxIB~
zTFlJJGc)oyZZ4&C42=&K*I83pjGIavDq87_5-M-X&yRA9frCcpBm)+l3^m$#Eh!mV
zTbDP+(}lxt1mFAZhq`wE?#`#b`*kxNF8*+?C||12yoAZ3{DqfX94g%w1K{8W%$)MP
zI17r(TCF@k^k(PO?CEpGq`Ab4xwaQ0L&2hzo0UJlq2qB#D;a+rO$9jcxuUww#WPAf
zKd{PKC-ub{Q!}2ouDWBA>V?a2u!P|eZOj!NFVs5LQ~;z$8a?Mc|GaU^L}661VYuRg
zpdhb!=~1YER_OgdtaVNw%NX<{t<5jwhu`5S0vMSuHhOGZ!52}`{Aci_^ry<|(wCK$
z<v8yKA$frFZUmLrJ)Ab
zSX@z3D88EZl+02^jvRpT!J-NhD#wlQ1=jb^8Q))3MlU5JQ7Tx5E^z^X7mY+tt0*$I
z-S(npBMy}~Q=I9KLScI*ImH}bSxaL9@Z#d4bwT_73FGG$Wt&YJi}6$MSa99%2E>=`
zALft&cp>A>n|$%IH*3q@%-mm+#uBAZHy4w(!gqlHyliDDXwNSgX)W4#y~g6P#sXk!
z&RXrlwiylZ;M6E_y8Q4uS>1D-4#-^>VJe0l;4D%8qA?w>AkE0N86`u}Ov`
zSiA+0If4a1=D3ap!1SY#8;lMb3xHSISdx_|kchy*3s*aQQ9Qu>i;*S=5;8Sf1TV
zfrA8KkMx*Ph$90ao5I2x3xEuQMe@UOKNbM55{tJ0vYUqp766%s%BWy*BnyCvb7e4D
zxTGJz^uglBoE4(50LW?-;;ayW%yEGnfUFBkdK7YldjNRpWN~B83bDolV2WHxV~qts
z<_H!5nM;jAtg!%?d=_$!FChZ>e>4^@kOMGzV^Mqw5x^@5mQjs`)6D=T&J{PfJnJhe
z3xJGgAr@}|WR6h?KqjLQm-GY3JXl7536V7x0Fy_{m{Eu$17M0+8QoaoUn|6~6ap{_
zn{30Q(1_RpfC(xKqY!{haz%|Ch4^+efQgf3%&-68tPp^#W+6r)02#;1zy)&2DIv$H
zApm=wg!HovQLM25$R=3^vSZg8i)JVSFyS!t4Bu`BkU74D2q2S5Xk@S`7H2-%Rw(@25$7Ejp3#V0A3$+RsUInUm&{R3mnv3vAw3U8Z~Qc5-O|B2TM!U
zJO6$l`${TU!jZuuA8MNX?1pk`-s#BGzSCN1
z3_30EIc)O9x4oJ9?`qFK+kK}g&|rXl+f;hr0g|Rtzb8%QURdikmCBcYzPIz)AGcF2
zrIl(e?NOsp=dX;bOyj|=9UHZmR1|L5c5^0w{M@7b(bL-GieGm&GR7|)Q~Ba02^WF|
z*nev(6$zB3c>J-gMk%b7*SbyRUmMMhs)!eyRoEOOMFWOPqz4MkbmmO
zzdSSh=GChvKeH(FmS^&_C#1n#mPW(rXkeeQVXzg|%C{s?HY8Z;@!6klQ@Or1B2?1r
zEAi&WHI{yJNj{+J_rEB~?h~CW%t?dsrMb*{a|?gd#-Hh&Oma33DV-Y5H2-^m*ECq#
zK@jegb|hRH^6x#2_hXvMz@lxE8zUP_y0xU6i=S-i$A0Q{y=w#TnomTjf?-nuvU~iA
gQt#<}=6?bV0OaPQ<9R*q;{X5v07*qoM6N<$f|%i+tpET3
literal 0
HcmV?d00001
diff --git a/astrid/res/drawable/icn_premium_checkmark.png b/astrid/res/drawable/icn_premium_checkmark.png
new file mode 100644
index 0000000000000000000000000000000000000000..f7bd23960db9d4f583fc4fcd7feb344b1a3fc3e8
GIT binary patch
literal 249
zcmeAS@N?(olHy`uVBq!ia0vp^l0YoX!3HE#i=ja@bw#?Oh0jMc}$PW
zI2M;|Y{UQ3NpTNa7|auxr>$<__;}#9xU6c1$PXpX1wtu|
zHSuB=j$sN4-iv=S#DzWhzU$gCwKRp=3hpZ`Me!9Zub4I+zQI|_e6%y)!P!A@rNdzd
u(UT5p4nlf7-kNiY`5)-LjMQCoguVUf=0{hrYfb<=43LzZL??3-X~|m->J%Bydr%C001x)xXS3cYC3#!^)PV;1KyfDn1ZPl>`W}c8ekK1FQ*}}2mtWR(n?FuRZm4($jreG
zX!3Uq(9_Nlfd&9X#62BN%xu7})TUqyD|=B|Xlo}ewUxOjtuC($hl-;V*wRYg+Zn9s
zt*T|_ZDS^APAe`(E#fJJAYcb}HKF#jv$b~-@)V{0M^^}O{kI!LOZ{(%tBok_e}vLg
zc}Fef;0&hb1#+;Paqw|c^YH*Vc{l`l1lg#$I5;^#oLnFdes)d{AzpqV4o>R-dC?-c
zIh%hH(vX(@A6|%WqO_K-u8u+=kcWo{(1RQ3;A{cn6ciK$ad3gSxY!XR*j>ErT}?dM
z?Oo{pOFH|RUf0CP>1g
zQ12}}i8++JI9`)-yu&l$4g%mN=J3
zl-&8BmK%Fm?BL>%jPR@WhEspi`M1K?eSga%|GPqy_}2iC(Ehd50Hl8{3iZDx5P%LH(DGC4lVq@!x>wa`)AfYmf)#&OF
zs&Lzy?l5o8UQY3$ed5lkdE)M4lVNlJ++b?m##%`mzwV`!WzU)6`xM=7yZI^UWU3E^
zkw!1S8nOcGXfpV}mGw@Pk$1XgEuS0V!I~G}U3#7v9l_oQ6|l5lhFlh;_IW+DxzhV>
z$Lnrq&Hh>Ba;PYT&_behH|x9~3w?9urvgVV)3kcsM2%B1pasm47N2&CPH%O=O%aJu
z*+(Ci=^qcxwOj@lTo}L+2+=&)#;QXeW_qI6aDLzC(FmD2+#I=nORDkpQKT{-q@7=C
zB+Jy&;$
zbKL_3SM=q+ayHQ4r}9LzRlsZvx8L3rSqXcD!ow-=y
zSxaTk5jZWrv)^y81W65L`KltTZBnpFmap4j-04
z#s%U}+-mhT7w^4>dyCRD2M0vQ#%H&TlZZ*Z@2@O-jS}a+KF3Z+EzSM7T3R^b@9T|HU7lwGC!Ji*6u+IdD$An
zF63)KsYV(WP$h2P!7Qkh3?03L`Cp91`Pg?19cEOXpSR8W3OYeA7y0E4{Zq8gfy@_C
zb8F>S0~)Z^G5)c0vF;)Wn%C88eG6o*ZD65j+~VQE?FKDlf~X(byz=WCHW+#E(-XUS5#V(>7auw5=g@ia4E!|k
zv`ciJb-O7YaNVw6VjsXCl#B;Ydz#K#gh5&gsO~R1`7~}jG0#(EgINlL;bLBoqfrBN
z^M3B$%oBExmj}J*B}A1G>PbuLCN(R>HicdqzL)R-%}!sZylLC~+L+*
zQmk7>{jZDO(10|P*^H~DqOYql$gk4uYeU?aVEam>&!GWb8
zVYWzRui!~Wwhn|{GNdpQq&DX1Q-1>}dOdb#X!~M*f@@^w?dK-+_5*Wz?N4C6+Ipja
zYTO1>c3xff@BFN^(YmMA);WqSY$*n3F=G+4;Cf}P_d&h8)L*{1^Yqi=Cnf1K3K0bm
zO`OIM?Kp`lB-8^7_O{71_;kQoPJ7!qUq@Zs_jZkx4IcUj`Lh!Qy<9Yzj!0!QTe$oa
zOSa3aTl-g!?qm%gWvejT?#8qVvR=B69zaKDmW}I`j`~j=TGQj3I+ZsjjL}k#86}5B
z9~GS@BwZdp&?UCBT8SC&*7kNrhBx*QPE^cJcj9$4yGJ*6_?CT-EN*O%cxSx5$A4~M
zg=*+>)AV}p1Zu#P8f8c)TjD9^a=?ux$l)?2<}pzF4enAnRk|Ga^%ar5M}^a&vGJfc
zj{-AW1-{Fnf3lg&JaJ3y)gbRVu)L5zLPK|I1?GLeK;dy9uez|>;WXj67qjZp8?>2O
z9OWMCvoYm)N;?0|rz7a3o4;!J3f&>BzayziL
zG-+Iqk-5RuvTj|IUE3Z5)?Ch9G2GAT+^%#hN9stMVK8Z@jm?jcrY-)C9+!uSH&JT$
z!ke2q2gm)P9wz*aF3A>U@vU?Uj2<3IZFs}Ul<;lmGq=vF5u&;9$YwN@enWPv+OJe#C^lj7v(Y~u0H)Wl#ka>)TOTuHY(lo>!OV)z}
zDik5QLL?)C7q|C`@gBEr>rS~>d#hd!%@#Ys>J#<5y1C`^DjNxv%C*DR2xSRsFA8aqjkQKy9Gb!i%=j%IZG3oHaI
z?&-@vQB_rrCb!>0UhNq@P`H5X`A?gGWDg@Xaw7%WfeiYh4`jEZNH=Y5v3gXLm2Hhj
z;y;4=2ek)dpQe=A^9RN{6$^+;BXonkk4AEw#ya;%pJ-Q#Jnh;AvNfVm(zN5kmi!ou
zoo8=g2GFG)wJntXldRHnvF)c3aaW76RUeAxkdNb!BDaD8r~XE*os|YX95j%){N&U3
z!Fb$R?7q#}Tp%J)`Q2q>*ecgRoZ}TyJAf=MW
z@Z>NkP*_Oy0g}Ynu|a<_#g}+tNT*y;?J~uAi?9q!7FMGK3zs`3*hNr!a8|670isVbyg!)sC@&Sm^blyEC{=2Dvan1Wzk>S0Jul#+YO@2X6p*j90ZT%N7im5*T
zLm2`v(09n}n+>o+{5+bcz~T8>ZaQ)NUJ6yyrQbpKS&Z=01Gegs^u~Q?2Rfa}WC~sY
zF#Q<%#5VKJ+5emb#IV@rN(EDP-k5Yg-CG&pojIhdrf7i_Tn3Ck#8ttUW{MIuiGZ)a
zHvpsfwm~>`-_`I%Us13Vc@TM4UWb_CaMK~s>Eo$^$9+GM4^HtLKHtn{+KqFpPRs*I+Jv2lk!FWCuqNA$wG97(
zqMXi7zw++w7!NTOJmb=NjYT@qa4XISPMsejBxtxmOgS
zUjYKkr1atN@$M>{&GU36?n)E`qt`Gssf9Nu8G%~Yc)CL?cdB7(cwXheQ(Ft5N=+Q&
zC0!B2KyK1blL&-!-n@X8%J~w*aE2tkTTK@FS)N
z7v86Bs^5E0$$}^!4Uw3TK|Y(_krlt=G-r>wW0hRt>Vw=5p6$S$|K;^$)r$7%yC
z0cX9ieyvk3)$=Ldb(eWd!zRV6)!*^z6SIBBzM7a)`NM6Be!+)6){A8GJ;Q#LmTw>1
zm#IIAfkbta170PoYrwi)=Kp-EkciO;V5?KCnBPp*9sd3fr#WgENS+S4i%K`J$|CUs
z&dbwz7%Da^C96TsG14=XXi~k=gF3z+Rr=BcVRIE@6}VKA+z{m@GWEm@&>PYpw%|$r
z{tlR8N4FZtAsw66n0SosHSf=rg2>W0-_HiAEUI5OMI=0k=KmU$q_b20$+6QM&q_2d
z2Nn#VAmt{pZo(#8SY9h+!uZnu&|4JQ(dOvnt##dEm?!K;P{`^#r0Hb=g-bd(WVK#j
z75F6B*sr-@gxk2%t1T4Ue6$zWZhSh9_P%e-r};L#c9m-}F?*l4$Ktnnz5=g6V$Zzc
zSU8yBM@|?wXn%}>@+y%oHQUJJ&3|yu0PIXVY_c*!nDR|2;wT?>GU`VYoCr8KOH_qb
z+T^G6B@sAM`p~aaX7^vpLyCFyrWNWVUtx<&!g4BcrqN^TA9-7V%S&8
zXzJ#5+?SuyT-CHiYw7!y!(*Vpf{nIoTq;w2>vwkAnKNNF5&AXlwp2Bq(qo+BDu2rq
zQy7zyZ_iQrvxLL}hPNLFMe{fQ@W0Z*H#XtCJ>MYn#3A}Fev=Y+*b7^Wx_nkc8;mFm>HUMm4GERAZ&B_32*z
z{>wphMsNo929=jma(dO??bloYe$N*X{e{Ml|`pbFcMrefp=ahgF
z$(7{94!hOy8~!kY@ef#rkM33kY{9g&2*bWAd<1eg`)-Ej?OSI0)-epKLg-^1@*4d?L|D{yf