candy_check 0.1.0.pre → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (76) hide show
  1. checksums.yaml +5 -5
  2. data/.rubocop.yml +23 -0
  3. data/.ruby-version +1 -1
  4. data/.travis.yml +7 -8
  5. data/Guardfile +42 -0
  6. data/MIGRATION_GUIDE_0_2_0.md +141 -0
  7. data/README.md +86 -26
  8. data/Rakefile +1 -1
  9. data/candy_check.gemspec +33 -25
  10. data/lib/candy_check/app_store/receipt_collection.rb +5 -3
  11. data/lib/candy_check/app_store/subscription_verification.rb +25 -1
  12. data/lib/candy_check/app_store/verification.rb +1 -1
  13. data/lib/candy_check/app_store/verifier.rb +11 -11
  14. data/lib/candy_check/cli/app.rb +16 -33
  15. data/lib/candy_check/cli/commands/play_store.rb +12 -13
  16. data/lib/candy_check/play_store.rb +20 -10
  17. data/lib/candy_check/play_store/acknowledger.rb +19 -0
  18. data/lib/candy_check/play_store/android_publisher_service.rb +6 -0
  19. data/lib/candy_check/play_store/product_acknowledgements/acknowledgement.rb +45 -0
  20. data/lib/candy_check/play_store/product_acknowledgements/response.rb +24 -0
  21. data/lib/candy_check/play_store/product_purchases/product_purchase.rb +90 -0
  22. data/lib/candy_check/play_store/product_purchases/product_verification.rb +53 -0
  23. data/lib/candy_check/play_store/subscription_purchases/subscription_purchase.rb +154 -0
  24. data/lib/candy_check/play_store/subscription_purchases/subscription_verification.rb +55 -0
  25. data/lib/candy_check/play_store/verification_failure.rb +8 -6
  26. data/lib/candy_check/play_store/verifier.rb +24 -49
  27. data/lib/candy_check/utils/config.rb +5 -3
  28. data/lib/candy_check/version.rb +1 -1
  29. data/spec/app_store/receipt_collection_spec.rb +33 -0
  30. data/spec/app_store/subscription_verification_spec.rb +35 -2
  31. data/spec/app_store/verifier_spec.rb +24 -5
  32. data/spec/candy_check_spec.rb +2 -2
  33. data/spec/cli/commands/play_store_spec.rb +10 -43
  34. data/spec/fixtures/play_store/random_dummy_key.json +12 -0
  35. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/acknowledged.yml +105 -0
  36. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/already_acknowledged.yml +124 -0
  37. data/spec/fixtures/vcr_cassettes/play_store/product_acknowledgements/refunded.yml +122 -0
  38. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/permission_denied.yml +196 -0
  39. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/response_with_empty_body.yml +183 -0
  40. data/spec/fixtures/vcr_cassettes/play_store/product_purchases/valid_but_not_consumed.yml +122 -0
  41. data/spec/fixtures/vcr_cassettes/play_store/subscription_purchases/permission_denied.yml +196 -0
  42. data/spec/fixtures/vcr_cassettes/play_store/subscription_purchases/valid_but_expired.yml +127 -0
  43. data/spec/play_store/acknowledger_spec.rb +48 -0
  44. data/spec/play_store/product_acknowledgements/acknowledgement_spec.rb +54 -0
  45. data/spec/play_store/product_acknowledgements/response_spec.rb +66 -0
  46. data/spec/play_store/product_purchases/product_purchase_spec.rb +110 -0
  47. data/spec/play_store/product_purchases/product_verification_spec.rb +49 -0
  48. data/spec/play_store/subscription_purchases/subscription_purchase_spec.rb +237 -0
  49. data/spec/play_store/subscription_purchases/subscription_verification_spec.rb +65 -0
  50. data/spec/play_store/verification_failure_spec.rb +18 -18
  51. data/spec/play_store/verifier_spec.rb +32 -96
  52. data/spec/spec_helper.rb +32 -10
  53. metadata +175 -75
  54. data/lib/candy_check/play_store/client.rb +0 -126
  55. data/lib/candy_check/play_store/config.rb +0 -51
  56. data/lib/candy_check/play_store/discovery_repository.rb +0 -33
  57. data/lib/candy_check/play_store/receipt.rb +0 -81
  58. data/lib/candy_check/play_store/subscription.rb +0 -138
  59. data/lib/candy_check/play_store/subscription_verification.rb +0 -30
  60. data/lib/candy_check/play_store/verification.rb +0 -48
  61. data/spec/fixtures/api_cache.dump +0 -1
  62. data/spec/fixtures/play_store/api_cache.dump +0 -1
  63. data/spec/fixtures/play_store/auth_failure.txt +0 -18
  64. data/spec/fixtures/play_store/auth_success.txt +0 -20
  65. data/spec/fixtures/play_store/discovery.txt +0 -2841
  66. data/spec/fixtures/play_store/dummy.p12 +0 -0
  67. data/spec/fixtures/play_store/empty.txt +0 -17
  68. data/spec/fixtures/play_store/products_failure.txt +0 -29
  69. data/spec/fixtures/play_store/products_success.txt +0 -22
  70. data/spec/play_store/client_spec.rb +0 -125
  71. data/spec/play_store/config_spec.rb +0 -96
  72. data/spec/play_store/discovery_respository_spec.rb +0 -31
  73. data/spec/play_store/receipt_spec.rb +0 -88
  74. data/spec/play_store/subscription_spec.rb +0 -138
  75. data/spec/play_store/subscription_verification_spec.rb +0 -98
  76. data/spec/play_store/verification_spec.rb +0 -82
@@ -1,126 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # A client which uses the official Google API SDK to authenticate
4
- # and request product information from Google's API.
5
- #
6
- # @example Usage
7
- # config = ClientConfig.new({...})
8
- # client = Client.new(config)
9
- # client.boot! # a single time
10
- # client.verify('my.bundle', 'product_1', 'a-very-long-secure-token')
11
- # # ... multiple calls from now on
12
- # client.verify('my.bundle', 'product_1', 'another-long-token')
13
- class Client
14
- # Error thrown if the discovery of the API wasn't successful
15
- class DiscoveryError < RuntimeError; end
16
-
17
- # API endpoint
18
- API_URL = 'https://accounts.google.com/o/oauth2/token'.freeze
19
- # API scope for Android services
20
- API_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'.freeze
21
- # API discovery namespace
22
- API_DISCOVER = 'androidpublisher'.freeze
23
- # API version
24
- API_VERSION = 'v2'.freeze
25
-
26
- # Initializes a client using a configuration.
27
- # @param config [ClientConfig]
28
- def initialize(config)
29
- @config = config
30
- end
31
-
32
- # Boots a client by discovering the API's services and then authorizes
33
- # by fetching an access token.
34
- # If the config has a cache_file the client tries to load discovery
35
- def boot!
36
- @api_client = Google::APIClient.new(
37
- application_name: config.application_name,
38
- application_version: config.application_version
39
- )
40
- discover!
41
- authorize!
42
- end
43
-
44
- # Calls the remote API to load the product information for a specific
45
- # combination of parameter which should be loaded from the client.
46
- # @param package [String] the app's package name
47
- # @param product_id [String] the app's item id
48
- # @param token [String] the purchase token
49
- # @return [Hash] result of the API call
50
- def verify(package, product_id, token)
51
- parameters = {
52
- 'packageName' => package,
53
- 'productId' => product_id,
54
- 'token' => token
55
- }
56
- execute(parameters, rpc.purchases.products.get)
57
- end
58
-
59
- # Calls the remote API to load the product information for a specific
60
- # combination of parameter which should be loaded from the client.
61
- # @param package [String] the app's package name
62
- # @param subscription_id [String] the app's item id
63
- # @param token [String] the purchase token
64
- # @return [Hash] result of the API call
65
- def verify_subscription(package, subscription_id, token)
66
- parameters = {
67
- 'packageName' => package,
68
- 'subscriptionId' => subscription_id,
69
- 'token' => token
70
- }
71
- execute(parameters, rpc.purchases.subscriptions.get)
72
- end
73
-
74
- private
75
-
76
- attr_reader :config, :api_client, :rpc
77
-
78
- # Execute api call through the API Client's HTTP command class
79
- # @param parameters [hash] the parameters to send to the command
80
- # @param api_method [Method] which api method to call
81
- # @return [hash] the data response, as a hash
82
- def execute(parameters, api_method)
83
- api_client.execute(
84
- api_method: api_method,
85
- parameters: parameters
86
- ).data.to_hash
87
- end
88
-
89
- def discover!
90
- @rpc = load_discover_dump || request_discover
91
- validate_rpc!
92
- write_discover_dump
93
- end
94
-
95
- def request_discover
96
- api_client.discovered_api(API_DISCOVER, API_VERSION)
97
- end
98
-
99
- def authorize!
100
- api_client.authorization = Signet::OAuth2::Client.new(
101
- token_credential_uri: API_URL,
102
- audience: API_URL,
103
- scope: API_SCOPE,
104
- issuer: config.issuer,
105
- signing_key: config.api_key
106
- )
107
- api_client.authorization.fetch_access_token!
108
- end
109
-
110
- def validate_rpc!
111
- return if rpc.purchases.products.get
112
- raise DiscoveryError, 'Unable to get the API discovery'
113
- rescue NoMethodError
114
- raise DiscoveryError, 'Unable to get the API discovery'
115
- end
116
-
117
- def load_discover_dump
118
- DiscoveryRepository.new(config.cache_file).load
119
- end
120
-
121
- def write_discover_dump
122
- DiscoveryRepository.new(config.cache_file).save(rpc)
123
- end
124
- end
125
- end
126
- end
@@ -1,51 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # Configure the usage of the official Google API SDK client
4
- class Config < Utils::Config
5
- # @return [String] your application name
6
- attr_reader :application_name
7
- # @return [String] your application's version
8
- attr_reader :application_version
9
- # @return [String] an optional file to cache the discovery API result
10
- attr_reader :cache_file
11
- # @return [String] your issuer's service account e-mail
12
- attr_reader :issuer
13
- # @return [String] the path to your local *.p12 certificate file
14
- attr_reader :key_file
15
- # @return [String] the secret to load your certificate file
16
- attr_reader :key_secret
17
-
18
- # Initializes a new configuration from a hash
19
- # @param attributes [Hash]
20
- # @example Initialize with a discovery cache file
21
- # ClientConfig.new(
22
- # application_name: 'YourApplication',
23
- # application_version: '1.0',
24
- # cache_file: 'tmp/google_api_cache',
25
- # issuer: 'abcdefg@developer.gserviceaccount.com',
26
- # key_file: 'local/google.p12',
27
- # key_secret: 'notasecret'
28
- # )
29
- def initialize(attributes)
30
- super
31
- end
32
-
33
- # @return [String] the decrypted API key from Google
34
- def api_key
35
- @api_key ||= begin
36
- Google::APIClient::KeyUtils.load_from_pkcs12(key_file, key_secret)
37
- end
38
- end
39
-
40
- private
41
-
42
- def validate!
43
- validates_presence(:application_name)
44
- validates_presence(:application_version)
45
- validates_presence(:issuer)
46
- validates_presence(:key_file)
47
- validates_presence(:key_secret)
48
- end
49
- end
50
- end
51
- end
@@ -1,33 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # A file-based repository to cache a local copy of the Google API
4
- # discovery as suggested by Google.
5
- # @see https://github.com/google/google-api-ruby-client
6
- class DiscoveryRepository
7
- # Create a new instance bound to a single file path
8
- # @param file_path [String] to save and load the cached copy
9
- def initialize(file_path)
10
- @file_path = file_path
11
- end
12
-
13
- # Tries to load a cached copy of the discovery API. Me be nil if
14
- # no cached version is available
15
- # @return [Google::APIClient::API]
16
- def load
17
- return unless @file_path && File.exist?(@file_path)
18
- File.open(@file_path, 'rb') do |file|
19
- return Marshal.load(file)
20
- end
21
- end
22
-
23
- # Tries to save a local copy of the discovery API.
24
- # @param discovery [Google::APIClient::API]
25
- def save(discovery)
26
- return unless @file_path && discovery
27
- File.open(@file_path, 'wb') do |file|
28
- Marshal.dump(discovery, file)
29
- end
30
- end
31
- end
32
- end
33
- end
@@ -1,81 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # Describes a successful response from the Google verification server
4
- class Receipt
5
- include Utils::AttributeReader
6
-
7
- # @return [Hash] the raw attributes returned from the server
8
- attr_reader :attributes
9
-
10
- # Purchased product (0 is purchased, don't ask me why)
11
- # @see https://developers.google.com/android-publisher/api-ref/purchases/products
12
- PURCHASE_STATE_PURCHASED = 0
13
-
14
- # A consumed product
15
- CONSUMPTION_STATE_CONSUMED = 1
16
-
17
- # Initializes a new instance which bases on a JSON result
18
- # from Google API servers
19
- # @param attributes [Hash]
20
- def initialize(attributes)
21
- @attributes = attributes
22
- end
23
-
24
- # A product may be purchased or canceled. Ensure a receipt
25
- # is valid before granting some candy
26
- # @return [Boolean]
27
- def valid?
28
- purchase_state == PURCHASE_STATE_PURCHASED
29
- end
30
-
31
- # A purchased product may already be consumed. In this case you
32
- # should grant candy even if it's valid.
33
- # @return [Boolean]
34
- def consumed?
35
- consumption_state == CONSUMPTION_STATE_CONSUMED
36
- end
37
-
38
- # The purchase state of the order. Possible values are:
39
- # * 0: Purchased
40
- # * 1: Cancelled
41
- # @return [Fixnum]
42
- def purchase_state
43
- read_integer('purchaseState')
44
- end
45
-
46
- # The consumption state of the inapp product. Possible values are:
47
- # * 0: Yet to be consumed
48
- # * 1: Consumed
49
- # @return [Fixnum]
50
- def consumption_state
51
- read_integer('consumptionState')
52
- end
53
-
54
- # The developer payload which was used when buying the product
55
- # @return [String]
56
- def developer_payload
57
- read('developerPayload')
58
- end
59
-
60
- # This kind represents an inappPurchase object in the androidpublisher
61
- # service.
62
- # @return [String]
63
- def kind
64
- read('kind')
65
- end
66
-
67
- # The time the product was purchased, in milliseconds since the
68
- # epoch (Jan 1, 1970)
69
- # @return [Fixnum]
70
- def purchase_time_millis
71
- read_integer('purchaseTimeMillis')
72
- end
73
-
74
- # The date and time the product was purchased
75
- # @return [DateTime]
76
- def purchased_at
77
- read_datetime_from_millis('purchaseTimeMillis')
78
- end
79
- end
80
- end
81
- end
@@ -1,138 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # Describes a succeful subscription validation
4
- class Subscription
5
- include Utils::AttributeReader
6
-
7
- # @return [Hash] the raw attributes returned from the server
8
- attr_reader :attributes
9
-
10
- # The payment of the subscription is pending (paymentState)
11
- PAYMENT_PENDING = 0
12
- # The payment of the subscript is received (paymentState)
13
- PAYMENT_RECEIVED = 1
14
- # The subscription was canceled by the user (cancelReason)
15
- PAYMENT_CANCELED = 0
16
- # The payment failed during processing (cancelReason)
17
- PAYMENT_FAILED = 1
18
-
19
- # Initializes a new instance which bases on a JSON result
20
- # from Google's servers
21
- # @param attributes [Hash]
22
- def initialize(attributes)
23
- @attributes = attributes
24
- end
25
-
26
- # Check if the expiration date is passed
27
- # @return [bool]
28
- def expired?
29
- overdue_days > 0
30
- end
31
-
32
- # Check if in trial. This is actually not given by Google, but we assume
33
- # that it is a trial going on if the paid amount is 0 and
34
- # renewal is activated.
35
- # @return [bool]
36
- def trial?
37
- price_is_zero = price_amount_micros == 0
38
- price_is_zero && payment_received?
39
- end
40
-
41
- # see if payment is ok
42
- # @return [bool]
43
- def payment_received?
44
- payment_state == PAYMENT_RECEIVED
45
- end
46
-
47
- # see if payment is pending
48
- # @return [bool]
49
- def payment_pending?
50
- payment_state == PAYMENT_PENDING
51
- end
52
-
53
- # see if payment has failed according to Google
54
- # @return [bool]
55
- def payment_failed?
56
- cancel_reason == PAYMENT_FAILED
57
- end
58
-
59
- # see if this the user has canceled its subscription
60
- # @return [bool]
61
- def canceled_by_user?
62
- cancel_reason == PAYMENT_CANCELED
63
- end
64
-
65
- # Get number of overdue days. If this is negative, it is not overdue.
66
- # @return [Integer]
67
- def overdue_days
68
- (Date.today - expires_at.to_date).to_i
69
- end
70
-
71
- # Get the auto renewal status as given by Google
72
- # @return [bool] true if renewing automatically, false otherwise
73
- def auto_renewing?
74
- read_bool('autoRenewing')
75
- end
76
-
77
- # Get the payment state as given by Google
78
- # @return [Integer]
79
- def payment_state
80
- read_integer('paymentState')
81
- end
82
-
83
- # Get the price amount for the subscription in micros in the payd currency
84
- # @return [Integer]
85
- def price_amount_micros
86
- read_integer('priceAmountMicros')
87
- end
88
-
89
- # Get the cancel reason, as given by Google
90
- # @return [Integer]
91
- def cancel_reason
92
- read_integer('cancelReason')
93
- end
94
-
95
- # Get the kind of subscription as stored in the android publisher service
96
- # @return [String]
97
- def kind
98
- read('kind')
99
- end
100
-
101
- # Get developer-specified supplemental information about the order
102
- # @return [String]
103
- def developer_payload
104
- read('developerPayload')
105
- end
106
-
107
- # Get the currency code in ISO 4217 format, e.g. "GBP" for British pounds
108
- # @return [String]
109
- def price_currency_code
110
- read('priceCurrencyCode')
111
- end
112
-
113
- # Get start time for subscription in milliseconds since Epoch
114
- # @return [Integer]
115
- def start_time_millis
116
- read_integer('startTimeMillis')
117
- end
118
-
119
- # Get expiry time for subscription in milliseconds since Epoch
120
- # @return [Integer]
121
- def expiry_time_millis
122
- read_integer('expiryTimeMillis')
123
- end
124
-
125
- # Get start time in UTC
126
- # @return [DateTime]
127
- def starts_at
128
- read_datetime_from_millis('startTimeMillis')
129
- end
130
-
131
- # Get expiration time in UTC
132
- # @return [DateTime]
133
- def expires_at
134
- read_datetime_from_millis('expiryTimeMillis')
135
- end
136
- end
137
- end
138
- end
@@ -1,30 +0,0 @@
1
- module CandyCheck
2
- module PlayStore
3
- # Verifies a purchase token against the Google API
4
- # The call return either an {Receipt} or an {VerificationFailure}
5
- class SubscriptionVerification < Verification
6
- # Performs the verification against the remote server
7
- # @return [Subscription] if successful
8
- # @return [VerificationFailure] otherwise
9
- def call!
10
- verify!
11
- if valid?
12
- Subscription.new(@response)
13
- else
14
- VerificationFailure.new(@response['error'])
15
- end
16
- end
17
-
18
- private
19
-
20
- def valid?
21
- ok_kind = @response['kind'] == 'androidpublisher#subscriptionPurchase'
22
- @response && @response['expiryTimeMillis'] && ok_kind
23
- end
24
-
25
- def verify!
26
- @response = @client.verify_subscription(package, product_id, token)
27
- end
28
- end
29
- end
30
- end