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 @@ + + + + +