├── .github ├── FUNDING.yml └── workflows │ ├── bob.yml │ └── trigger-site-rebuild.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── alpha_testers.png ├── android_purchase.png ├── google_play_products.png ├── index.md ├── ios_confirm_purchase.png ├── ios_purchase_done.png ├── itunes_connect_google_play.png └── itunes_products.png ├── extension-iap ├── api │ └── iap.script_api ├── ext.manifest ├── lib │ ├── android │ │ └── in-app-purchasing-2.0.76.jar │ └── web │ │ └── library_facebook_iap.js ├── manifests │ └── android │ │ ├── AndroidManifest.xml │ │ ├── build.gradle │ │ └── extension-iap.pro └── src │ ├── iap.h │ ├── iap_android.cpp │ ├── iap_emscripten.cpp │ ├── iap_ios.mm │ ├── iap_null.cpp │ ├── iap_private.cpp │ ├── iap_private.h │ └── java │ └── com │ └── defold │ └── iap │ ├── IListProductsListener.java │ ├── IPurchaseListener.java │ ├── IapAmazon.java │ ├── IapGooglePlay.java │ └── IapJNI.java ├── game.project ├── input └── game.input_binding └── main ├── main.collection ├── main.gui └── main.gui_script /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: defold 2 | patreon: Defold 3 | custom: ['https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=NBNBHTUW4GS4C'] 4 | -------------------------------------------------------------------------------- /.github/workflows/bob.yml: -------------------------------------------------------------------------------- 1 | name: Build with bob 2 | 3 | on: 4 | push: 5 | pull_request_target: 6 | schedule: 7 | # nightly at 05:00 on the 1st and 15th 8 | - cron: 0 5 1,15 * * 9 | 10 | env: 11 | VERSION_FILENAME: 'info.json' 12 | BUILD_SERVER: 'https://build.defold.com' 13 | 14 | jobs: 15 | build_with_bob: 16 | strategy: 17 | matrix: 18 | platform: [armv7-android, x86_64-linux, x86_64-win32, x86-win32, js-web] 19 | runs-on: ubuntu-latest 20 | 21 | name: Build 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-java@v1 25 | with: 26 | java-version: '11.0.2' 27 | 28 | - name: Get Defold version 29 | run: | 30 | TMPVAR=`curl -s http://d.defold.com/stable/${{env.VERSION_FILENAME}} | jq -r '.sha1'` 31 | echo "DEFOLD_VERSION=${TMPVAR}" >> $GITHUB_ENV 32 | echo "Found version ${TMPVAR}" 33 | 34 | - name: Download bob.jar 35 | run: | 36 | wget -q http://d.defold.com/archive/stable/${{env.DEFOLD_VERSION}}/bob/bob.jar 37 | java -jar bob.jar --version 38 | 39 | - name: Resolve libraries 40 | run: java -jar bob.jar resolve --email a@b.com --auth 123456 41 | - name: Build 42 | run: java -jar bob.jar --platform=${{ matrix.platform }} build --archive --build-server=${{env.BUILD_SERVER}} 43 | - name: Bundle 44 | run: java -jar bob.jar --platform=${{ matrix.platform }} bundle 45 | 46 | # macOS is not technically needed for building, but we want to test bundling as well, since we're also testing the manifest merging 47 | build_with_bob_macos: 48 | strategy: 49 | matrix: 50 | platform: [armv7-darwin, x86_64-darwin] 51 | runs-on: macOS-latest 52 | 53 | name: Build 54 | steps: 55 | - uses: actions/checkout@v2 56 | - uses: actions/setup-java@v1 57 | with: 58 | java-version: '11.0.2' 59 | 60 | - name: Get Defold version 61 | run: | 62 | TMPVAR=`curl -s http://d.defold.com/stable/${{env.VERSION_FILENAME}} | jq -r '.sha1'` 63 | echo "DEFOLD_VERSION=${TMPVAR}" >> $GITHUB_ENV 64 | echo "Found version ${TMPVAR}" 65 | 66 | - name: Download bob.jar 67 | run: | 68 | wget -q http://d.defold.com/archive/stable/${{env.DEFOLD_VERSION}}/bob/bob.jar 69 | java -jar bob.jar --version 70 | 71 | - name: Resolve libraries 72 | run: java -jar bob.jar resolve --email a@b.com --auth 123456 73 | - name: Build 74 | run: java -jar bob.jar --platform=${{ matrix.platform }} build --archive --build-server=${{env.BUILD_SERVER}} 75 | - name: Bundle 76 | run: java -jar bob.jar --platform=${{ matrix.platform }} bundle 77 | -------------------------------------------------------------------------------- /.github/workflows/trigger-site-rebuild.yml: -------------------------------------------------------------------------------- 1 | name: Trigger site rebuild 2 | 3 | on: [push] 4 | 5 | jobs: 6 | site-rebuild: 7 | runs-on: ubuntu-latest 8 | 9 | steps: [ 10 | { 11 | name: 'Repository dispatch', 12 | uses: defold/repository-dispatch@1.2.1, 13 | with: { 14 | repo: 'defold/defold.github.io', 15 | token: '${{ secrets.SERVICES_GITHUB_TOKEN }}', 16 | user: 'services@defold.se', 17 | action: 'extension-iap' 18 | } 19 | }] 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.internal 2 | /build 3 | .externalToolBuilders 4 | .DS_Store 5 | Thumbs.db 6 | .lock-wscript 7 | *.pyc 8 | .project 9 | .cproject 10 | builtins 11 | _site 12 | manifest.private.der 13 | manifest.public.der 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Defold 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status Alpha](https://github.com/defold/extension-iap/actions/workflows/bob.yml/badge.svg)](https://github.com/defold/extension-iap/actions) 2 | 3 | # In-app purchase extension for Defold 4 | 5 | Defold [native extension](https://www.defold.com/manuals/extensions/) which provides access to In-app purchase functionality on iOS, Android (Google Play and Amazon) and Facebook Canvas platforms. 6 | 7 | [Manual, API and setup instructions](https://www.defold.com/extension-iap/) is available on the official Defold site. 8 | -------------------------------------------------------------------------------- /docs/alpha_testers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/alpha_testers.png -------------------------------------------------------------------------------- /docs/android_purchase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/android_purchase.png -------------------------------------------------------------------------------- /docs/google_play_products.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/google_play_products.png -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Defold In-app purchase extension API documentation 3 | brief: This manual covers how to setup and use In-App Purchases in Defold. 4 | --- 5 | 6 | # Defold In-app purchase extension API documentation 7 | 8 | This extension provides a unified, simple to use interface to several different stores for in-app purchase: 9 | 10 | * Apple’s iOS Appstore - StoreKit 11 | * Google Play Billing 5.0 12 | * Amazon 'in-app billing' 2.0.61 13 | * Facebook Canvas 'game payments' 14 | 15 | These services gives you the opportunity to sell products as: 16 | 17 | * Standard in-app products (one time billing) of consumables or non-consumables and 18 | * Subscriptions (recurring, automated billing) 19 | 20 | ::: important 21 | The current Defold interface allows full interaction with Apple's Storekit functionality. For Google Play and Facebook Canvas, the interface is identical, meaning that you can run the same code on either platform. However, some process flow might differ from platform to platform. Also note that there is currently no support for OS X purchases through the Mac Appstore. 22 | ::: 23 | 24 | Detailed documentation from Apple, Google, Amazon and Facebook can be found here: 25 | 26 | * [In-App Purchase Programming Guide](https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/StoreKitGuide/Introduction.html). 27 | * [Google Play In-app Billing documentation](http://developer.android.com/google/play/billing/index.html). 28 | * [Amazon In-app Purchase documentation](https://developer.amazon.com/public/apis/earn/in-app-purchasing). 29 | * [Facebook game payments documentation](https://developers.facebook.com/docs/payments). 30 | 31 | ## Installation 32 | To use this library in your Defold project, add the following URL to your `game.project` dependencies: 33 | 34 | [https://github.com/defold/extension-iap/archive/master.zip](https://github.com/defold/extension-iap/archive/master.zip) 35 | 36 | We recommend using a link to a zip file of a [specific release](https://github.com/defold/extension-iap/releases). 37 | 38 | For Facebook Canvas you also need to add the [Facebook extension as a dependency](https://github.com/defold/extension-facebook). 39 | 40 | 41 | ## Testing Google Play Billing with static responses 42 | 43 | On Android it is recommended that you start implementing IAP in your app by using static responses from Google Play. This enables you to verify that everything in your app works correctly before you publish the app. Four reserved product IDs exist for testing static In-app Billing responses: 44 | 45 | `android.test.purchased` 46 | : Google Play responds as though you successfully purchased an item. The response includes a JSON string, which contains fake purchase information (for example, a fake order ID). 47 | 48 | `android.test.canceled` 49 | : Google Play responds as though the purchase was canceled. This can occur when an error is encountered in the order process, such as an invalid credit card, or when you cancel a user's order before it is charged. 50 | 51 | `android.test.refunded` 52 | : Google Play responds as though the purchase was refunded. 53 | 54 | `android.test.item_unavailable` 55 | : Google Play responds as though the item being purchased was not listed in your application's product list. 56 | 57 | 58 | ## Setting up your app for purchases/billing 59 | 60 | The procedure on iOS and Android is similar: 61 | 62 | 1. Make sure you are a registered Apple or Google Play developer. 63 | 2. Set up your project so it works on your target device. See the [iOS development](/manuals/ios) and [Android development](/manuals/android) guides. 64 | 3. Set up the app for testing: 65 | 66 | - For Android, this is done on the [Google Play Developer Console](https://play.google.com/apps/publish/). 67 | - For iOS, this is done on [iTunes Connect](https://itunesconnect.apple.com/). Make sure that your App ID (created in the "Member Center" on https://developer.apple.com) has "In-App Purchase" enabled. 68 | 69 | ![iTunes Connect and Google Play Dev Console](itunes_connect_google_play.png) 70 | 71 | 4. For Google Play, you need to _upload and publish_ an alpha *.apk* file. For iTunes Connect, you should _not upload_ the development binary to iTunes Connect until the application is ready for App Review approval. If you upload a binary to iTunes Connect and it is not fully functional, Apple will likely reject it. 72 | 73 | 5. Create products for your app. 74 | 75 | ![iTunes Products](itunes_products.png) 76 | 77 | ![Google Play Products](google_play_products.png) 78 | 79 | 6. Add test users. 80 | - The iTunes Connect page *Users and Roles* allow you to add users that can do test purchases in the _sandbox environment_. You should sign your app with a Developer certificate and use the sandbox account in Appstore on the test device. 81 | - From the Google Play Developer Console, choose *Settings > Account Details* where you can add user emails to the License Testing section. Separate the emails by commas. This allows your testers to use test purchases that don’t actually cost real money. 82 | - On Google Play, you also need to set up a Google Group for your testers. Google uses Groups to manage testers that can download your app from the Alpha and Beta stores. Click on the *Alpha Testing* tab and then *Manage list of testers* to add your Google Group as Alpha testers. The app must have passed through alpha publishing before you can see the opt-in link. 83 | 84 | ![Alpha testers](alpha_testers.png) 85 | 86 | The procedure on Facebook: 87 | 88 | 1. Make sure you are a registered Facebook developer. Go to [Facebook for developers](https://developers.facebook.com/), "My Apps" and "Register as a developer", follow the steps. 89 | 2. Facebook has extensive payment functionality and requires support of both synchronous and asynchronous payments. More info here [Payment overview](https://developers.facebook.com/docs/payments/overview) 90 | 3. Set up app hosting and callback server: 91 | * You will need to set up a secure canvas URL hosting your project. How this works is explained here [Games on Facebook](https://developers.facebook.com/docs/games/gamesonfacebook/hosting). 92 | * The next step is to set up your callback server. Follow the steps here [Setting up your callback server](https://developers.facebook.com/docs/payments/realtimeupdates#yourcallbackserver). 93 | 4. Set up you canvas app. Follow the steps on [Facebook Developer Dashboard](https://developers.facebook.com/quickstarts/?platform=canvas). 94 | 5. Add test users. This is done in the "Canvas Payments" section of the app dashboard. 95 | 6. Create products for your app [Defining products](https://developers.facebook.com/docs/payments/implementation-guide/defining-products/). 96 | 97 | 98 | ## Asynchronous transactions 99 | 100 | The IAP API is asynchronous, meaning that after each request that your program sends to the server, the program will not halt and wait for a response. Instead, the program continues as ordinary and when the response arrives, a _callback_ function is invoked where you can react to the response data. 101 | 102 | To fetch all product information available: 103 | 104 | ```lua 105 | local COINS_ID = "com.defold.examples.coins" 106 | local LOGO_ID = "com.defold.examples.logo" 107 | 108 | local function product_list(self, products, error) 109 | if error == nil then 110 | for i,p in pairs(products) do 111 | print(p.ident) 112 | print(p.title) 113 | print(p.description) 114 | print(p.currency_code) 115 | print(p.price_string) 116 | end 117 | else 118 | print(error.error) 119 | end 120 | end 121 | 122 | function init(self) 123 | -- Initiate a fetch of products (max 20 at a time for Google Play) 124 | iap.list({ COINS_ID, LOGO_ID }, product_list) 125 | end 126 | ``` 127 | 128 | To perform actual transactions, first register a function that will listen to transaction results, then call the store function at the appropriate time: 129 | 130 | ```lua 131 | local function iap_listener(self, transaction, error) 132 | if error == nil then 133 | if transaction.state == iap.TRANS_STATE_PURCHASING then 134 | print("Purchasing...") 135 | elseif transaction.state == iap.TRANS_STATE_PURCHASED then 136 | print("Purchased!") 137 | elseif transaction.state == iap.TRANS_STATE_UNVERIFIED then 138 | print("Unverified!") 139 | elseif transaction.state == iap.TRANS_STATE_FAILED then 140 | print("Failed!") 141 | elseif transaction.state == iap.TRANS_STATE_RESTORED then 142 | print("Restored") 143 | end 144 | else 145 | print(error.error) 146 | end 147 | end 148 | 149 | function on_message(self, message_id, message, sender) 150 | ... 151 | -- Register the function that will listen to IAP transactions. 152 | iap.set_listener(iap_listener) 153 | -- Initiate a purchase of a coin... 154 | iap.buy(COINS_ID) 155 | ... 156 | end 157 | ``` 158 | 159 | The device operating system will automatically show a pop-up window allowing the user to go through with the purchase. The interface clearly indicates when you are running in the test/sandbox environment. 160 | 161 | ![Confirm purchase](ios_confirm_purchase.png) 162 | 163 | ![Android purchase](android_purchase.png) 164 | 165 | ![Confirm purchase](ios_purchase_done.png) 166 | 167 | 168 | ## Synchronous payments 169 | 170 | Most payment providers only supports synchronous payments. This means that the client (your application) will receive a notification when the payment is complete, TRANS_STATE_PURCHASED. This is the final state of the payment, meaning no more callbacks will be done on this transaction. 171 | 172 | 173 | ## Asynchronous payments 174 | 175 | Some payment providers require supporting asynchronous payments. This means that the client (your application) will only receive a notification when the payment is initiated. In order to verify completion of payment, further communication needs to be done between the developer server (or client) and the payment provider in order to verify. 176 | 177 | In the case of an initiated asynchronous payment the IAP listener will receive the state TRANS_STATE_UNVERIFIED to indicate this (as opposed to TRANS_STATE_PURCHASED). This is the final state of the payment, meaning no more callbacks will be done on this transaction. 178 | 179 | 180 | ## Purchase fulfilment 181 | 182 | In order to complete a purchase from a payment provider, the application needs to signal a purchase fulfilment to the provider telling the provider the purchase has gone through (for example by developer server-side verification). 183 | 184 | IAP supports auto-completion, where fulfilment is automatically signalled to the provider when a purchase is complete (this is the default behavior). You can also disable auto-completion in the game project settings. You are then required to call `iap.finish()` when the transaction is complete, which will signal purchase fulfilment to the provider. 185 | 186 | 187 | ### Consumable vs non-consumable products 188 | 189 | The Google Play store does only support consumable products. If you need non-consumable products it is recommended to use manual fulfilment of purchases and never finish purchases for products that should be non-consumable. As long as a purchase isn't finished it will be returned as an active purchase when `iap.set_listener()` is called. If you do not call `iap.finish()` on a purchase you still need to indicate to Google Play that the purchase has been handled. You can do this by calling `iap.acknowledge()`. If you do not call `iap.acknowledge()` the purchase will be automatically refunded by Google after a few days. 190 | 191 | The Apple App Store supports non-consumable products which means that you need to finish all purchases when you provide products to your users. You can do it automatically by keeping the default behavior in the game project settings or manually (if you want to do that after server validation, for example) using `iap.finish()`. 192 | 193 | 194 | ## Transaction receipt 195 | 196 | The receipt is a signed chunk of data that can be sent to the App Store to verify that the payment was successfully processed. This is most useful when designing a store that uses a separate server to verify that payment was processed. 197 | 198 | 199 | ## Differences between supported platforms 200 | 201 | Amazon supports two different product types: subscriptions and consumable products. 202 | 203 | Google Play and Apple supports three different product types: subscriptions, consumable and non-consumable products. 204 | 205 | If you want to simulate non-consumable products on Amazon you need to make sure to not call `iap.finish()` on the product in question (and make sure to not have enabled Auto Finish Transactions in *game.project*). 206 | 207 | Calls to `iap.buy()` and `iap.set_listener()` will return all non-finished purchases on Google Play. (This will not happen on iOS) 208 | 209 | The concept of restoring purchases does not exist on Google Play/Amazon. Calls to `iap.restore()` on iOS will return all purchased products (and have product state set to TRANS_STATE_RESTORED). Calls to `iap.restore()` on Google Play will return all non-finished purchases (and have product state set to TRANS_STATE_PURCHASED). 210 | 211 | 212 | ## Troubleshooting 213 | 214 | Android `iap.list()` returns "failed to fetch product" 215 | : You need to upload and publish an *.apk* on the alpha or beta channels on the Google Play Developer Console. Also make sure that the _time and date_ on your device is correct. 216 | 217 | Android (Google Play) `iap.list()` never returns more than 20 products 218 | : Google has a [limit of 20 products per request](https://github.com/android/play-billing-samples/blob/7a94c6905a9c125518354c216b5c3094fde47ce1/TrivialDrive/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl#L62). The solution is to make multiple calls to `iap.list()` and combine the results if the number of products exceeds 20. 219 | 220 | iOS `iap.list()` returns nothing 221 | : Make sure that you’ve requested an iOS Paid Applications account, and all proper documentation has been filed. Without proper authorization, your iOS app purchasing (even test purchases) will not work. 222 | 223 | Check that the AppId you have on the "Member Center" has in-app purchases activated and that you are signing your app (or the dev-app) with a provisioning profile that is up to date with the AppId (check the "Enabled Services:" field in the provisioning profile details in the "Certificates, Identifiers & Profiles" area of "Member Center") 224 | 225 | Wait. It can take a few hours for the In-App product IDs to propagate to the Sandbox environment. 226 | 227 | iOS `iap.list()` fails logging error "Unexpected callback set" 228 | : `iap.list()` does not support nested calls. Calling `iap.list()` from an `iap.list()` callback function will be ignored, with the engine logging this error. 229 | 230 | On iOS, the "price_string" field contains '~' characters 231 | : The '~' characters are placeholders where no matching character could be found in the font file. The "price_string" field returned in the product list when using `iap.list()` is formatted with a _non breaking space_ (`\u00a0`) between the value and the currency denominator. If you render this string in the GUI, you need to add the character to the font's *extra_characters* field. On Mac OS X you can type non breaking spaces by pressing Option + SPACE. See http://en.wikipedia.org/wiki/Non-breaking_space for more information. 232 | 233 | 234 | ## Source code 235 | 236 | The source code is available on [GitHub](https://github.com/defold/extension-iap) 237 | -------------------------------------------------------------------------------- /docs/ios_confirm_purchase.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/ios_confirm_purchase.png -------------------------------------------------------------------------------- /docs/ios_purchase_done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/ios_purchase_done.png -------------------------------------------------------------------------------- /docs/itunes_connect_google_play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/itunes_connect_google_play.png -------------------------------------------------------------------------------- /docs/itunes_products.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/docs/itunes_products.png -------------------------------------------------------------------------------- /extension-iap/api/iap.script_api: -------------------------------------------------------------------------------- 1 | - name: iap 2 | type: table 3 | desc: Functions and constants for doing in-app purchases. Supported on iOS, Android (Google Play and Amazon) 4 | and Facebook Canvas platforms. 5 | [icon:ios] [icon:googleplay] [icon:amazon] [icon:facebook] 6 | members: 7 | 8 | #***************************************************************************************************** 9 | 10 | - name: buy 11 | type: function 12 | desc: Purchase a product. 13 | parameters: 14 | - name: id 15 | type: string 16 | desc: product to buy 17 | 18 | - name: options 19 | type: table 20 | desc: optional parameters as properties. The following parameters can be set 21 | members: 22 | - name: request_id 23 | type: string 24 | desc: Facebook only [icon:facebook]. Optional custom unique request id to 25 | set for this transaction. The id becomes attached to the payment within the Graph API. 26 | - name: token 27 | type: string 28 | desc: Google Play only [icon:googleplay]. Which subscription offer to use when buying a subscription. The token can be retrieved from 29 | the subscriptions table returned when calling iap.list() 30 | 31 | examples: 32 | - desc: |- 33 | ```lua 34 | local function iap_listener(self, transaction, error) 35 | if error == nil then 36 | -- purchase is successful. 37 | print(transaction.date) 38 | -- required if auto finish transactions is disabled in project settings 39 | if (transaction.state == iap.TRANS_STATE_PURCHASED) then 40 | -- do server-side verification of purchase here.. 41 | iap.finish(transaction) 42 | end 43 | else 44 | print(error.error, error.reason) 45 | end 46 | end 47 | 48 | function init(self) 49 | iap.set_listener(iap_listener) 50 | iap.buy("my_iap") 51 | end 52 | ``` 53 | 54 | #***************************************************************************************************** 55 | 56 | - name: finish 57 | type: function 58 | desc: Explicitly finish a product transaction. 59 | [icon:attention] Calling iap.finish is required on a successful transaction 60 | if `auto_finish_transactions` is disabled in project settings. Calling this function 61 | with `auto_finish_transactions` set will be ignored and a warning is printed. 62 | The `transaction.state` field must equal `iap.TRANS_STATE_PURCHASED`. 63 | parameters: 64 | - name: transaction 65 | type: table 66 | desc: transaction table parameter as supplied in listener callback 67 | 68 | #***************************************************************************************************** 69 | 70 | - name: acknowledge 71 | type: function 72 | desc: Acknowledge a transaction. 73 | [icon:attention] Calling iap.acknowledge is required on a successful transaction on Google 74 | Play unless iap.finish is called. The transaction.state field must equal iap.TRANS_STATE_PURCHASED. 75 | parameters: 76 | - name: transaction 77 | type: table 78 | desc: transaction table parameter as supplied in listener callback 79 | 80 | #***************************************************************************************************** 81 | 82 | - name: get_provider_id 83 | type: function 84 | desc: Get current iap provider 85 | returns: 86 | - name: provider_id 87 | type: constant 88 | desc: one of the following values 89 | 90 | - `iap.PROVIDER_ID_GOOGLE` 91 | 92 | - `iap.PROVIDER_ID_AMAZON` 93 | 94 | - `iap.PROVIDER_ID_APPLE` 95 | 96 | - `iap.PROVIDER_ID_FACEBOOK` 97 | 98 | #***************************************************************************************************** 99 | 100 | - name: list 101 | type: function 102 | desc: Get a list of all avaliable iap products. 103 | parameters: 104 | - name: ids 105 | type: table 106 | desc: table (array) of identifiers to get products from 107 | 108 | - name: callback 109 | type: function 110 | desc: result callback taking the following parameters 111 | parameters: 112 | - name: self 113 | type: object 114 | desc: The current object. 115 | 116 | - name: products 117 | type: table 118 | desc: a table describing the available iap products. 119 | members: 120 | - name: ident 121 | type: string 122 | desc: The product identifier. 123 | 124 | - name: title 125 | type: string 126 | desc: The product title. 127 | 128 | - name: description 129 | type: string 130 | desc: The product description. 131 | 132 | - name: price 133 | type: number 134 | desc: The price of the product. 135 | For Google Play [icon:googleplay] this is used only for in-app products 136 | 137 | - name: price_string 138 | type: string 139 | desc: The price of the product, as a formatted string (amount and currency symbol). 140 | For Google Play [icon:googleplay] this is used only for in-app products 141 | 142 | - name: currency_code 143 | type: string 144 | desc: The currency code. 145 | For Google Play [icon:googleplay] this is the merchant's locale, instead of the user's. 146 | For Google Play [icon:googleplay] this is used only for in-app products 147 | 148 | - name: subscriptions 149 | type: table 150 | desc: Only available for Google Play [icon:googleplay]. List of subscription offers. 151 | Each offer contains a token and a list of price and billing options. 152 | See https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.PricingPhase 153 | members: 154 | - name: token 155 | type: string 156 | desc: The token associated with the pricing phases for the subscription. 157 | 158 | - name: pricing 159 | type: table 160 | desc: The pricing phases for the subscription. 161 | members: 162 | - name: price_string 163 | type: string 164 | desc: Formatted price for the payment cycle, including currency sign. 165 | 166 | - name: price 167 | type: number 168 | desc: Price of the payment cycle in micro-units. 169 | 170 | - name: currency_code 171 | type: string 172 | desc: ISO 4217 currency code 173 | 174 | - name: billing_period 175 | type: string 176 | desc: Billing period of the payment cycle, specified in ISO 8601 format 177 | 178 | - name: billing_cycle_count 179 | type: number 180 | desc: Number of cycles for which the billing period is applied. 181 | 182 | - name: recurrence_mode 183 | type: string 184 | desc: FINITE, INFINITE or NONE 185 | 186 | - name: error 187 | type: table 188 | desc: a table containing error information. `nil` if there is no error. - `error` (the error message) 189 | 190 | examples: 191 | - desc: |- 192 | ```lua 193 | local function iap_callback(self, products, error) 194 | if error == nil then 195 | for k,p in pairs(products) do 196 | -- present the product 197 | print(p.title) 198 | print(p.description) 199 | end 200 | else 201 | print(error.error) 202 | end 203 | end 204 | 205 | function init(self) 206 | iap.list({"my_iap"}, iap_callback) 207 | end 208 | ``` 209 | 210 | #***************************************************************************************************** 211 | 212 | - name: restore 213 | type: function 214 | desc: Restore previously purchased products. 215 | returns: 216 | - name: success 217 | type: boolean 218 | desc: value is `true` if current store supports handling 219 | restored transactions, otherwise `false`. 220 | 221 | #***************************************************************************************************** 222 | 223 | - name: set_listener 224 | type: function 225 | desc: Set the callback function to receive purchase transaction events. 226 | parameters: 227 | - name: listener 228 | type: function 229 | desc: listener callback function. Pass an empty function if you no longer wish to receive callbacks. 230 | parameters: 231 | - name: self 232 | type: object 233 | desc: The current object. 234 | 235 | - name: transaction 236 | type: table 237 | desc: a table describing the transaction. 238 | members: 239 | - name: ident 240 | type: string 241 | desc: The product identifier. 242 | 243 | - name: state 244 | type: string 245 | desc: The transaction state. One of the following 246 | 247 | - `iap.TRANS_STATE_FAILED` 248 | 249 | - `iap.TRANS_STATE_PURCHASED` 250 | 251 | - `iap.TRANS_STATE_PURCHASING` 252 | 253 | - `iap.TRANS_STATE_RESTORED` 254 | 255 | - `iap.TRANS_STATE_UNVERIFIED` 256 | 257 | - name: date 258 | type: string 259 | desc: The date and time for the transaction. 260 | 261 | - name: trans_ident 262 | type: string 263 | desc: The transaction identifier. This field is only set when `state` is 264 | `TRANS_STATE_RESTORED`, `TRANS_STATE_UNVERIFIED` or `TRANS_STATE_PURCHASED`. 265 | 266 | - name: receipt 267 | type: string 268 | desc: The transaction receipt. This field is only set when `state` is `TRANS_STATE_PURCHASED` or `TRANS_STATE_UNVERIFIED`. 269 | 270 | - name: original_trans 271 | type: string 272 | desc: Apple only [icon:apple]. The original transaction. This field is only set when `state` is `TRANS_STATE_RESTORED`. 273 | 274 | - name: original_json 275 | type: string 276 | desc: Android only [icon:android]. The purchase order details in JSON format. 277 | 278 | - name: signature 279 | type: string 280 | desc: Google Play only [icon:googleplay]. A string containing the signature of the purchase data that was signed with the private key of the developer. 281 | 282 | - name: request_id 283 | type: string 284 | desc: Facebook only [icon:facebook]. This field is set to the optional custom unique request id `request_id` if set in the `iap.buy()` call parameters. 285 | 286 | - name: user_id 287 | type: string 288 | desc: Amazon Pay only [icon:amazon]. The user ID. 289 | 290 | - name: is_sandbox_mode 291 | type: boolean 292 | desc: Amazon Pay only [icon:amazon]. If `true`, the SDK is running in Sandbox mode. 293 | This only allows interactions with the Amazon AppTester. Use this mode only for testing locally. 294 | 295 | - name: cancel_date 296 | type: string 297 | desc: Amazon Pay only [icon:amazon]. The cancel date for the purchase. This field is only set if the purchase is canceled. 298 | 299 | - name: canceled 300 | type: string 301 | desc: Amazon Pay only [icon:amazon]. Is set to `true` if the receipt was canceled or has expired; otherwise `false`. 302 | 303 | - name: error 304 | type: table 305 | desc: a table containing error information. `nil` if there is no error. `error` - the error message. 306 | `reason` - the reason for the error, value can be one of the following constants 307 | 308 | - `iap.REASON_UNSPECIFIED` 309 | 310 | - `iap.REASON_USER_CANCELED` 311 | 312 | #***************************************************************************************************** 313 | 314 | - name: PROVIDER_ID_AMAZON 315 | type: number 316 | desc: provider id for Amazon 317 | 318 | - name: PROVIDER_ID_APPLE 319 | type: number 320 | desc: provider id for Apple 321 | 322 | - name: PROVIDER_ID_FACEBOOK 323 | type: number 324 | desc: provider id for Facebook 325 | 326 | - name: PROVIDER_ID_GOOGLE 327 | type: number 328 | desc: iap provider id for Google 329 | 330 | - name: REASON_UNSPECIFIED 331 | type: number 332 | desc: unspecified error reason 333 | 334 | - name: REASON_USER_CANCELED 335 | type: number 336 | desc: user canceled reason 337 | 338 | - name: TRANS_STATE_FAILED 339 | type: number 340 | desc: transaction failed state 341 | 342 | - name: TRANS_STATE_PURCHASED 343 | type: number 344 | desc: transaction purchased state 345 | 346 | - name: TRANS_STATE_PURCHASING 347 | type: number 348 | desc: transaction purchasing state 349 | This is an intermediate mode followed by TRANS_STATE_PURCHASED. Store provider support dependent. 350 | 351 | - name: TRANS_STATE_RESTORED 352 | type: number 353 | desc: transaction restored state 354 | This is only available on store providers supporting restoring purchases. 355 | 356 | - name: TRANS_STATE_UNVERIFIED 357 | type: number 358 | desc: transaction unverified state, requires verification of purchase 359 | -------------------------------------------------------------------------------- /extension-iap/ext.manifest: -------------------------------------------------------------------------------- 1 | name: IAPExt 2 | 3 | platforms: 4 | arm64-ios: 5 | context: 6 | weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] 7 | 8 | x86_64-ios: 9 | context: 10 | weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] 11 | -------------------------------------------------------------------------------- /extension-iap/lib/android/in-app-purchasing-2.0.76.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/defold/extension-iap/88028fd29f7d53cfebef752a7944814eddc44142/extension-iap/lib/android/in-app-purchasing-2.0.76.jar -------------------------------------------------------------------------------- /extension-iap/lib/web/library_facebook_iap.js: -------------------------------------------------------------------------------- 1 | 2 | var LibraryFacebookIAP = { 3 | $FBinner: { 4 | // NOTE: Also defined in iap.h 5 | TransactionState : 6 | { 7 | TRANS_STATE_PURCHASING : 0, 8 | TRANS_STATE_PURCHASED : 1, 9 | TRANS_STATE_FAILED : 2, 10 | TRANS_STATE_RESTORED : 3, 11 | TRANS_STATE_UNVERIFIED : 4 12 | }, 13 | 14 | // NOTE: Also defined in iap.h 15 | BillingResponse : 16 | { 17 | BILLING_RESPONSE_RESULT_OK : 0, 18 | BILLING_RESPONSE_RESULT_USER_CANCELED : 1, 19 | BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE : 3, 20 | BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE : 4, 21 | BILLING_RESPONSE_RESULT_DEVELOPER_ERROR : 5, 22 | BILLING_RESPONSE_RESULT_ERROR : 6, 23 | BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED : 7, 24 | BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED : 8 25 | }, 26 | 27 | // See Facebook definitions https://developers.facebook.com/docs/payments/reference/errorcodes 28 | FBPaymentResponse : 29 | { 30 | FB_PAYMENT_RESPONSE_USERCANCELED : 1383010, 31 | FB_PAYMENT_RESPONSE_APPINVALIDITEMPARAM : 1383051 32 | }, 33 | 34 | http_callback: function(xmlhttp, callback, lua_callback, products, product_ids, product_count, url_index, url_count) { 35 | if (xmlhttp.readyState == 4) { 36 | if(xmlhttp.status == 200) { 37 | var xmlDoc = document.createElement( 'html' ); 38 | xmlDoc.innerHTML = xmlhttp.responseText; 39 | var elements = xmlDoc.getElementsByTagName('meta'); 40 | 41 | var productInfo = {}; 42 | for (var i=0; i 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /extension-iap/manifests/android/build.gradle: -------------------------------------------------------------------------------- 1 | repositories { 2 | mavenCentral() 3 | } 4 | 5 | dependencies { 6 | implementation 'com.android.billingclient:billing:7.0.0' 7 | } 8 | -------------------------------------------------------------------------------- /extension-iap/manifests/android/extension-iap.pro: -------------------------------------------------------------------------------- 1 | -keep class com.defold.iap.** { 2 | public ; 3 | } 4 | 5 | -------------------------------------------------------------------------------- /extension-iap/src/iap.h: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) 2 | 3 | #ifndef DM_IAP_EXTENSION 4 | #define DM_IAP_EXTENSION 5 | 6 | // NOTE: Also defined in library_facebook_iap.js 7 | // NOTE: Also defined in IapJNI.java 8 | 9 | enum TransactionState 10 | { 11 | TRANS_STATE_PURCHASING = 0, 12 | TRANS_STATE_PURCHASED = 1, 13 | TRANS_STATE_FAILED = 2, 14 | TRANS_STATE_RESTORED = 3, 15 | TRANS_STATE_UNVERIFIED = 4, 16 | }; 17 | 18 | enum ErrorReason 19 | { 20 | REASON_UNSPECIFIED = 0, 21 | REASON_USER_CANCELED = 1, 22 | }; 23 | 24 | enum BillingResponse 25 | { 26 | BILLING_RESPONSE_RESULT_OK = 0, 27 | BILLING_RESPONSE_RESULT_USER_CANCELED = 1, 28 | BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3, 29 | BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4, 30 | BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5, 31 | BILLING_RESPONSE_RESULT_ERROR = 6, 32 | BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7, 33 | BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8, 34 | BILLING_RESPONSE_RESULT_NETWORK_ERROR = 9, 35 | }; 36 | 37 | enum ProviderId 38 | { 39 | PROVIDER_ID_GOOGLE = 0, 40 | PROVIDER_ID_AMAZON = 1, 41 | PROVIDER_ID_APPLE = 2, 42 | PROVIDER_ID_FACEBOOK = 3, 43 | }; 44 | 45 | #endif // DM_IAP_EXTENSION 46 | 47 | #endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS 48 | -------------------------------------------------------------------------------- /extension-iap/src/iap_android.cpp: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_ANDROID) 2 | 3 | #include 4 | #include 5 | 6 | #include 7 | #include 8 | #include "iap.h" 9 | #include "iap_private.h" 10 | 11 | #define LIB_NAME "iap" 12 | 13 | struct IAP 14 | { 15 | IAP() 16 | { 17 | memset(this, 0, sizeof(*this)); 18 | m_autoFinishTransactions = true; 19 | m_ProviderId = PROVIDER_ID_GOOGLE; 20 | } 21 | bool m_autoFinishTransactions; 22 | int m_ProviderId; 23 | 24 | dmScript::LuaCallbackInfo* m_Listener; 25 | 26 | jobject m_IAP; 27 | jobject m_IAPJNI; 28 | jmethodID m_List; 29 | jmethodID m_Stop; 30 | jmethodID m_Buy; 31 | jmethodID m_Restore; 32 | jmethodID m_ProcessPendingConsumables; 33 | jmethodID m_AcknowledgeTransaction; 34 | jmethodID m_FinishTransaction; 35 | 36 | IAPCommandQueue m_CommandQueue; 37 | }; 38 | 39 | static IAP g_IAP; 40 | 41 | static int IAP_ProcessPendingTransactions(lua_State* L) 42 | { 43 | DM_LUA_STACK_CHECK(L, 0); 44 | 45 | dmAndroid::ThreadAttacher threadAttacher; 46 | JNIEnv* env = threadAttacher.GetEnv(); 47 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI); 48 | 49 | return 0; 50 | } 51 | 52 | static int IAP_List(lua_State* L) 53 | { 54 | DM_LUA_STACK_CHECK(L, 0); 55 | 56 | char* buf = IAP_List_CreateBuffer(L); 57 | if( buf == 0 ) 58 | { 59 | return 0; 60 | } 61 | 62 | dmAndroid::ThreadAttacher threadAttacher; 63 | JNIEnv* env = threadAttacher.GetEnv(); 64 | IAPCommand* cmd = new IAPCommand; 65 | cmd->m_Callback = dmScript::CreateCallback(L, 2); 66 | cmd->m_Command = IAP_PRODUCT_RESULT; 67 | 68 | jstring products = env->NewStringUTF(buf); 69 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_List, products, g_IAP.m_IAPJNI, (jlong)cmd); 70 | env->DeleteLocalRef(products); 71 | 72 | free(buf); 73 | return 0; 74 | } 75 | 76 | static int IAP_Buy(lua_State* L) 77 | { 78 | DM_LUA_STACK_CHECK(L, 0); 79 | 80 | int top = lua_gettop(L); 81 | const char* id = luaL_checkstring(L, 1); 82 | const char* token = ""; 83 | 84 | if (top >= 2 && lua_istable(L, 2)) { 85 | luaL_checktype(L, 2, LUA_TTABLE); 86 | lua_pushvalue(L, 2); 87 | lua_getfield(L, -1, "token"); 88 | token = lua_isnil(L, -1) ? "" : luaL_checkstring(L, -1); 89 | lua_pop(L, 2); 90 | } 91 | 92 | dmAndroid::ThreadAttacher threadAttacher; 93 | JNIEnv* env = threadAttacher.GetEnv(); 94 | jstring ids = env->NewStringUTF(id); 95 | jstring tokens = env->NewStringUTF(token); 96 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Buy, ids, tokens, g_IAP.m_IAPJNI); 97 | env->DeleteLocalRef(ids); 98 | env->DeleteLocalRef(tokens); 99 | 100 | return 0; 101 | } 102 | 103 | static int IAP_Finish(lua_State* L) 104 | { 105 | DM_LUA_STACK_CHECK(L, 0); 106 | 107 | if(g_IAP.m_autoFinishTransactions) 108 | { 109 | dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored."); 110 | return 0; 111 | } 112 | 113 | luaL_checktype(L, 1, LUA_TTABLE); 114 | 115 | lua_getfield(L, -1, "state"); 116 | if (lua_isnumber(L, -1)) 117 | { 118 | if(lua_tointeger(L, -1) != TRANS_STATE_PURCHASED) 119 | { 120 | dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED)."); 121 | lua_pop(L, 1); 122 | return 0; 123 | } 124 | } 125 | lua_pop(L, 1); 126 | 127 | lua_getfield(L, -1, "receipt"); 128 | if (!lua_isstring(L, -1)) { 129 | dmLogError("Transaction error. Invalid transaction data, does not contain 'receipt' key."); 130 | lua_pop(L, 1); 131 | } 132 | else 133 | { 134 | const char * receipt = lua_tostring(L, -1); 135 | lua_pop(L, 1); 136 | 137 | dmAndroid::ThreadAttacher threadAttacher; 138 | JNIEnv* env = threadAttacher.GetEnv(); 139 | jstring receiptUTF = env->NewStringUTF(receipt); 140 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_FinishTransaction, receiptUTF, g_IAP.m_IAPJNI); 141 | env->DeleteLocalRef(receiptUTF); 142 | } 143 | 144 | return 0; 145 | } 146 | 147 | static int IAP_Acknowledge(lua_State* L) 148 | { 149 | DM_LUA_STACK_CHECK(L, 0); 150 | 151 | luaL_checktype(L, 1, LUA_TTABLE); 152 | 153 | lua_getfield(L, -1, "state"); 154 | if (lua_isnumber(L, -1)) 155 | { 156 | if(lua_tointeger(L, -1) != TRANS_STATE_PURCHASED) 157 | { 158 | dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED)."); 159 | lua_pop(L, 1); 160 | return 0; 161 | } 162 | } 163 | lua_pop(L, 1); 164 | 165 | lua_getfield(L, -1, "receipt"); 166 | if (!lua_isstring(L, -1)) { 167 | dmLogError("Transaction error. Invalid transaction data, does not contain 'receipt' key."); 168 | lua_pop(L, 1); 169 | } 170 | else 171 | { 172 | const char * receipt = lua_tostring(L, -1); 173 | lua_pop(L, 1); 174 | 175 | dmAndroid::ThreadAttacher threadAttacher; 176 | JNIEnv* env = threadAttacher.GetEnv(); 177 | jstring receiptUTF = env->NewStringUTF(receipt); 178 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_AcknowledgeTransaction, receiptUTF, g_IAP.m_IAPJNI); 179 | env->DeleteLocalRef(receiptUTF); 180 | } 181 | 182 | return 0; 183 | } 184 | 185 | static int IAP_Restore(lua_State* L) 186 | { 187 | // TODO: Missing callback here for completion/error 188 | // See iap_ios.mm 189 | DM_LUA_STACK_CHECK(L, 1); 190 | 191 | dmAndroid::ThreadAttacher threadAttacher; 192 | JNIEnv* env = threadAttacher.GetEnv(); 193 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Restore, g_IAP.m_IAPJNI); 194 | 195 | lua_pushboolean(L, 1); 196 | return 1; 197 | } 198 | 199 | static int IAP_SetListener(lua_State* L) 200 | { 201 | DM_LUA_STACK_CHECK(L, 0); 202 | 203 | IAP* iap = &g_IAP; 204 | 205 | bool had_previous = iap->m_Listener != 0; 206 | 207 | if (iap->m_Listener) 208 | dmScript::DestroyCallback(iap->m_Listener); 209 | 210 | iap->m_Listener = dmScript::CreateCallback(L, 1); 211 | 212 | // On first set listener, trigger process old ones. 213 | if (!had_previous) { 214 | dmAndroid::ThreadAttacher threadAttacher; 215 | JNIEnv* env = threadAttacher.GetEnv(); 216 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI); 217 | } 218 | return 0; 219 | } 220 | 221 | static int IAP_GetProviderId(lua_State* L) 222 | { 223 | DM_LUA_STACK_CHECK(L, 1); 224 | 225 | lua_pushinteger(L, g_IAP.m_ProviderId); 226 | return 1; 227 | } 228 | 229 | static const luaL_reg IAP_methods[] = 230 | { 231 | {"list", IAP_List}, 232 | {"buy", IAP_Buy}, 233 | {"finish", IAP_Finish}, 234 | {"acknowledge", IAP_Acknowledge}, 235 | {"restore", IAP_Restore}, 236 | {"set_listener", IAP_SetListener}, 237 | {"get_provider_id", IAP_GetProviderId}, 238 | {"process_pending_transactions", IAP_ProcessPendingTransactions}, 239 | {0, 0} 240 | }; 241 | 242 | 243 | #ifdef __cplusplus 244 | extern "C" { 245 | #endif 246 | 247 | 248 | JNIEXPORT void JNICALL Java_com_defold_iap_IapJNI_onProductsResult(JNIEnv* env, jobject, jint responseCode, jstring productList, jlong cmdHandle) 249 | { 250 | const char* pl = 0; 251 | if (productList) 252 | { 253 | pl = env->GetStringUTFChars(productList, 0); 254 | } 255 | 256 | IAPCommand* cmd = (IAPCommand*)cmdHandle; 257 | cmd->m_ResponseCode = responseCode; 258 | if (pl) 259 | { 260 | cmd->m_Data = strdup(pl); 261 | env->ReleaseStringUTFChars(productList, pl); 262 | } 263 | IAP_Queue_Push(&g_IAP.m_CommandQueue, cmd); 264 | } 265 | 266 | JNIEXPORT void JNICALL Java_com_defold_iap_IapJNI_onPurchaseResult__ILjava_lang_String_2(JNIEnv* env, jobject, jint responseCode, jstring purchaseData) 267 | { 268 | dmLogInfo("Java_com_defold_iap_IapJNI_onPurchaseResult__ILjava_lang_String_2 %d", (int)responseCode); 269 | const char* pd = 0; 270 | if (purchaseData) 271 | { 272 | pd = env->GetStringUTFChars(purchaseData, 0); 273 | } 274 | 275 | IAPCommand cmd; 276 | cmd.m_Callback = g_IAP.m_Listener; 277 | cmd.m_Command = IAP_PURCHASE_RESULT; 278 | cmd.m_ResponseCode = responseCode; 279 | if (pd) 280 | { 281 | cmd.m_Data = strdup(pd); 282 | env->ReleaseStringUTFChars(purchaseData, pd); 283 | } 284 | IAP_Queue_Push(&g_IAP.m_CommandQueue, &cmd); 285 | } 286 | 287 | #ifdef __cplusplus 288 | } 289 | #endif 290 | 291 | static void HandleProductResult(const IAPCommand* cmd) 292 | { 293 | if (cmd->m_Callback == 0) 294 | { 295 | dmLogWarning("Received product list but no listener was set!"); 296 | return; 297 | } 298 | 299 | lua_State* L = dmScript::GetCallbackLuaContext(cmd->m_Callback); 300 | int top = lua_gettop(L); 301 | 302 | if (!dmScript::SetupCallback(cmd->m_Callback)) 303 | { 304 | assert(top == lua_gettop(L)); 305 | return; 306 | } 307 | 308 | if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { 309 | const char* json = (const char*)cmd->m_Data; 310 | dmScript::JsonToLua(L, json, strlen(json)); // throws lua error if it fails 311 | lua_pushnil(L); 312 | } else { 313 | dmLogError("IAP error %d", cmd->m_ResponseCode); 314 | lua_pushnil(L); 315 | IAP_PushError(L, "failed to fetch product", REASON_UNSPECIFIED); 316 | } 317 | 318 | dmScript::PCall(L, 3, 0); 319 | 320 | dmScript::TeardownCallback(cmd->m_Callback); 321 | dmScript::DestroyCallback(cmd->m_Callback); 322 | 323 | assert(top == lua_gettop(L)); 324 | } 325 | 326 | static void HandlePurchaseResult(const IAPCommand* cmd) 327 | { 328 | if (cmd->m_Callback == 0) 329 | { 330 | dmLogWarning("Received purchase result but no listener was set!"); 331 | return; 332 | } 333 | 334 | lua_State* L = dmScript::GetCallbackLuaContext(cmd->m_Callback); 335 | int top = lua_gettop(L); 336 | 337 | if (!dmScript::SetupCallback(cmd->m_Callback)) 338 | { 339 | assert(top == lua_gettop(L)); 340 | return; 341 | } 342 | 343 | if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { 344 | if (cmd->m_Data != 0) { 345 | const char* json = (const char*)cmd->m_Data; 346 | dmScript::JsonToLua(L, json, strlen(json)); // throws lua error if it fails 347 | lua_pushnil(L); 348 | } else { 349 | dmLogError("IAP error, purchase response was null"); 350 | lua_pushnil(L); 351 | IAP_PushError(L, "purchase response was null", REASON_UNSPECIFIED); 352 | } 353 | } else if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_USER_CANCELED) { 354 | lua_pushnil(L); 355 | IAP_PushError(L, "user canceled purchase", REASON_USER_CANCELED); 356 | } else { 357 | dmLogError("IAP error %d", cmd->m_ResponseCode); 358 | lua_pushnil(L); 359 | IAP_PushError(L, "failed to buy product", REASON_UNSPECIFIED); 360 | } 361 | 362 | dmScript::PCall(L, 3, 0); 363 | 364 | dmScript::TeardownCallback(cmd->m_Callback); 365 | 366 | assert(top == lua_gettop(L)); 367 | } 368 | 369 | static dmExtension::Result InitializeIAP(dmExtension::Params* params) 370 | { 371 | IAP_Queue_Create(&g_IAP.m_CommandQueue); 372 | 373 | g_IAP.m_autoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; 374 | 375 | dmAndroid::ThreadAttacher threadAttacher; 376 | JNIEnv* env = threadAttacher.GetEnv(); 377 | 378 | const char* provider = dmConfigFile::GetString(params->m_ConfigFile, "android.iap_provider", "GooglePlay"); 379 | const char* class_name = "com.defold.iap.IapGooglePlay"; 380 | 381 | g_IAP.m_ProviderId = PROVIDER_ID_GOOGLE; 382 | if (!strcmp(provider, "Amazon")) { 383 | g_IAP.m_ProviderId = PROVIDER_ID_AMAZON; 384 | class_name = "com.defold.iap.IapAmazon"; 385 | } 386 | else if (strcmp(provider, "GooglePlay")) { 387 | dmLogWarning("Unknown IAP provider name [%s], defaulting to GooglePlay", provider); 388 | } 389 | 390 | jclass iap_class = dmAndroid::LoadClass(env, class_name); 391 | jclass iap_jni_class = dmAndroid::LoadClass(env, "com.defold.iap.IapJNI"); 392 | 393 | g_IAP.m_List = env->GetMethodID(iap_class, "listItems", "(Ljava/lang/String;Lcom/defold/iap/IListProductsListener;J)V"); 394 | g_IAP.m_Buy = env->GetMethodID(iap_class, "buy", "(Ljava/lang/String;Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V"); 395 | g_IAP.m_Restore = env->GetMethodID(iap_class, "restore", "(Lcom/defold/iap/IPurchaseListener;)V"); 396 | g_IAP.m_Stop = env->GetMethodID(iap_class, "stop", "()V"); 397 | g_IAP.m_ProcessPendingConsumables = env->GetMethodID(iap_class, "processPendingConsumables", "(Lcom/defold/iap/IPurchaseListener;)V"); 398 | g_IAP.m_FinishTransaction = env->GetMethodID(iap_class, "finishTransaction", "(Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V"); 399 | g_IAP.m_AcknowledgeTransaction = env->GetMethodID(iap_class, "acknowledgeTransaction", "(Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V"); 400 | 401 | jmethodID jni_constructor = env->GetMethodID(iap_class, "", "(Landroid/app/Activity;Z)V"); 402 | g_IAP.m_IAP = env->NewGlobalRef(env->NewObject(iap_class, jni_constructor, threadAttacher.GetActivity()->clazz, g_IAP.m_autoFinishTransactions)); 403 | 404 | jni_constructor = env->GetMethodID(iap_jni_class, "", "()V"); 405 | g_IAP.m_IAPJNI = env->NewGlobalRef(env->NewObject(iap_jni_class, jni_constructor)); 406 | 407 | lua_State*L = params->m_L; 408 | int top = lua_gettop(L); 409 | luaL_register(L, LIB_NAME, IAP_methods); 410 | 411 | IAP_PushConstants(L); 412 | 413 | lua_pop(L, 1); 414 | assert(top == lua_gettop(L)); 415 | 416 | return dmExtension::RESULT_OK; 417 | } 418 | 419 | static void IAP_OnCommand(IAPCommand* cmd, void*) 420 | { 421 | switch (cmd->m_Command) 422 | { 423 | case IAP_PRODUCT_RESULT: 424 | HandleProductResult(cmd); 425 | break; 426 | case IAP_PURCHASE_RESULT: 427 | HandlePurchaseResult(cmd); 428 | break; 429 | 430 | default: 431 | assert(false); 432 | } 433 | 434 | if (cmd->m_Data) { 435 | free(cmd->m_Data); 436 | } 437 | } 438 | 439 | static dmExtension::Result UpdateIAP(dmExtension::Params* params) 440 | { 441 | IAP_Queue_Flush(&g_IAP.m_CommandQueue, IAP_OnCommand, 0); 442 | return dmExtension::RESULT_OK; 443 | } 444 | 445 | static dmExtension::Result FinalizeIAP(dmExtension::Params* params) 446 | { 447 | IAP_Queue_Destroy(&g_IAP.m_CommandQueue); 448 | 449 | if (params->m_L == dmScript::GetCallbackLuaContext(g_IAP.m_Listener)) { 450 | dmScript::DestroyCallback(g_IAP.m_Listener); 451 | g_IAP.m_Listener = 0; 452 | } 453 | 454 | dmAndroid::ThreadAttacher threadAttacher; 455 | JNIEnv* env = threadAttacher.GetEnv(); 456 | env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Stop); 457 | env->DeleteGlobalRef(g_IAP.m_IAP); 458 | env->DeleteGlobalRef(g_IAP.m_IAPJNI); 459 | g_IAP.m_IAP = NULL; 460 | return dmExtension::RESULT_OK; 461 | } 462 | 463 | DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, UpdateIAP, 0, FinalizeIAP) 464 | 465 | #endif //DM_PLATFORM_ANDROID 466 | -------------------------------------------------------------------------------- /extension-iap/src/iap_emscripten.cpp: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_HTML5) 2 | 3 | #include 4 | 5 | #include 6 | #include 7 | 8 | #include "iap.h" 9 | #include "iap_private.h" 10 | 11 | #define LIB_NAME "iap" 12 | 13 | struct IAP 14 | { 15 | IAP() 16 | { 17 | memset(this, 0, sizeof(*this)); 18 | m_autoFinishTransactions = true; 19 | } 20 | 21 | dmScript::LuaCallbackInfo* m_Listener; 22 | int m_InitCount; 23 | bool m_autoFinishTransactions; 24 | } g_IAP; 25 | 26 | typedef void (*OnIAPFBList)(void* luacallback, const char* json); 27 | typedef void (*OnIAPFBListenerCallback)(void* luacallback, const char* json, int error_code); 28 | 29 | extern "C" { 30 | // Implementation in library_facebook_iap.js 31 | void dmIAPFBList(const char* item_ids, OnIAPFBList callback, dmScript::LuaCallbackInfo* luacallback); 32 | void dmIAPFBBuy(const char* item_id, const char* request_id, OnIAPFBListenerCallback callback, dmScript::LuaCallbackInfo* luacallback); 33 | } 34 | 35 | static void IAPList_Callback(void* luacallback, const char* result_json) 36 | { 37 | dmScript::LuaCallbackInfo* callback = (dmScript::LuaCallbackInfo*)luacallback; 38 | lua_State* L = dmScript::GetCallbackLuaContext(callback); 39 | DM_LUA_STACK_CHECK(L, 0); 40 | 41 | if (!dmScript::SetupCallback(callback)) 42 | { 43 | dmScript::DestroyCallback(callback); 44 | return; 45 | } 46 | 47 | if(result_json != 0) 48 | { 49 | dmScript::JsonToLua(L, result_json, strlen(result_json)); // throws lua error if it fails 50 | lua_pushnil(L); 51 | } 52 | else 53 | { 54 | dmLogError("Got empty list result."); 55 | lua_pushnil(L); 56 | IAP_PushError(L, "Got empty list result.", REASON_UNSPECIFIED); 57 | } 58 | 59 | dmScript::PCall(L, 3, 0); 60 | 61 | dmScript::DestroyCallback(callback); 62 | dmScript::TeardownCallback(callback); 63 | } 64 | 65 | static int IAP_ProcessPendingTransactions(lua_State* L) 66 | { 67 | return 0; 68 | } 69 | 70 | static int IAP_List(lua_State* L) 71 | { 72 | DM_LUA_STACK_CHECK(L, 0); 73 | 74 | char* buf = IAP_List_CreateBuffer(L); 75 | if( buf == 0 ) 76 | { 77 | return 0; 78 | } 79 | 80 | dmScript::LuaCallbackInfo* callback = dmScript::CreateCallback(L, 2); 81 | 82 | dmIAPFBList(buf, (OnIAPFBList)IAPList_Callback, callback); 83 | 84 | free(buf); 85 | return 0; 86 | } 87 | 88 | static void IAPListener_Callback(void* luacallback, const char* result_json, int error_code) 89 | { 90 | dmScript::LuaCallbackInfo* callback = (dmScript::LuaCallbackInfo*)luacallback; 91 | lua_State* L = dmScript::GetCallbackLuaContext(callback); 92 | DM_LUA_STACK_CHECK(L, 0); 93 | 94 | if (!dmScript::SetupCallback(callback)) 95 | { 96 | return; 97 | } 98 | 99 | if (result_json) { 100 | dmScript::JsonToLua(L, result_json, strlen(result_json)); // throws lua error if it fails 101 | lua_pushnil(L); 102 | } else { 103 | lua_pushnil(L); 104 | switch(error_code) 105 | { 106 | case BILLING_RESPONSE_RESULT_USER_CANCELED: 107 | IAP_PushError(L, "user canceled purchase", REASON_USER_CANCELED); 108 | break; 109 | 110 | case BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED: 111 | IAP_PushError(L, "product already owned", REASON_UNSPECIFIED); 112 | break; 113 | 114 | default: 115 | dmLogError("IAP error %d", error_code); 116 | IAP_PushError(L, "failed to buy product", REASON_UNSPECIFIED); 117 | break; 118 | } 119 | } 120 | 121 | dmScript::PCall(L, 3, 0); 122 | 123 | dmScript::TeardownCallback(callback); 124 | } 125 | 126 | 127 | static int IAP_Buy(lua_State* L) 128 | { 129 | DM_LUA_STACK_CHECK(L, 0); 130 | 131 | if (!g_IAP.m_Listener) { 132 | dmLogError("No callback set"); 133 | return 0; 134 | } 135 | 136 | int top = lua_gettop(L); 137 | const char* id = luaL_checkstring(L, 1); 138 | const char* request_id = 0x0; 139 | 140 | if (top >= 2 && lua_istable(L, 2)) { 141 | luaL_checktype(L, 2, LUA_TTABLE); 142 | lua_pushvalue(L, 2); 143 | lua_getfield(L, -1, "request_id"); 144 | request_id = lua_isnil(L, -1) ? 0x0 : luaL_checkstring(L, -1); 145 | lua_pop(L, 2); 146 | } 147 | 148 | dmIAPFBBuy(id, request_id, (OnIAPFBListenerCallback)IAPListener_Callback, g_IAP.m_Listener); 149 | return 0; 150 | } 151 | 152 | static int IAP_SetListener(lua_State* L) 153 | { 154 | DM_LUA_STACK_CHECK(L, 0); 155 | if (g_IAP.m_Listener) 156 | dmScript::DestroyCallback(g_IAP.m_Listener); 157 | g_IAP.m_Listener = dmScript::CreateCallback(L, 1); 158 | return 0; 159 | } 160 | 161 | static int IAP_Finish(lua_State* L) 162 | { 163 | return 0; 164 | } 165 | 166 | static int IAP_Acknowledge(lua_State* L) 167 | { 168 | return 0; 169 | } 170 | 171 | static int IAP_Restore(lua_State* L) 172 | { 173 | DM_LUA_STACK_CHECK(L, 1); 174 | lua_pushboolean(L, 0); 175 | return 1; 176 | } 177 | 178 | static int IAP_GetProviderId(lua_State* L) 179 | { 180 | DM_LUA_STACK_CHECK(L, 1); 181 | lua_pushinteger(L, PROVIDER_ID_FACEBOOK); 182 | return 1; 183 | } 184 | 185 | static const luaL_reg IAP_methods[] = 186 | { 187 | {"list", IAP_List}, 188 | {"buy", IAP_Buy}, 189 | {"finish", IAP_Finish}, 190 | {"acknowledge", IAP_Acknowledge}, 191 | {"restore", IAP_Restore}, 192 | {"set_listener", IAP_SetListener}, 193 | {"get_provider_id", IAP_GetProviderId}, 194 | {"process_pending_transactions", IAP_ProcessPendingTransactions}, 195 | {0, 0} 196 | }; 197 | 198 | static dmExtension::Result InitializeIAP(dmExtension::Params* params) 199 | { 200 | if (g_IAP.m_InitCount == 0) { 201 | g_IAP.m_autoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; 202 | } 203 | g_IAP.m_InitCount++; 204 | lua_State* L = params->m_L; 205 | int top = lua_gettop(L); 206 | luaL_register(L, LIB_NAME, IAP_methods); 207 | 208 | IAP_PushConstants(L); 209 | 210 | lua_pop(L, 1); 211 | assert(top == lua_gettop(L)); 212 | return dmExtension::RESULT_OK; 213 | } 214 | 215 | static dmExtension::Result FinalizeIAP(dmExtension::Params* params) 216 | { 217 | --g_IAP.m_InitCount; 218 | if (g_IAP.m_Listener && g_IAP.m_InitCount == 0) 219 | { 220 | dmScript::DestroyCallback(g_IAP.m_Listener); 221 | g_IAP.m_Listener = 0; 222 | } 223 | return dmExtension::RESULT_OK; 224 | } 225 | 226 | DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, 0, 0, FinalizeIAP) 227 | 228 | #endif // DM_PLATFORM_HTML5 229 | -------------------------------------------------------------------------------- /extension-iap/src/iap_ios.mm: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_IOS) 2 | 3 | #include 4 | 5 | #include "iap.h" 6 | #include "iap_private.h" 7 | 8 | #import 9 | #import 10 | #import 11 | 12 | #define LIB_NAME "iap" 13 | 14 | struct IAP; 15 | 16 | @interface SKPaymentTransactionObserver : NSObject 17 | @property IAP* m_IAP; 18 | @end 19 | 20 | struct IAP 21 | { 22 | IAP() 23 | { 24 | memset(this, 0, sizeof(*this)); 25 | m_AutoFinishTransactions = true; 26 | } 27 | int m_Version; 28 | bool m_AutoFinishTransactions; 29 | NSMutableDictionary* m_PendingTransactions; 30 | dmScript::LuaCallbackInfo* m_Listener; 31 | IAPCommandQueue m_CommandQueue; 32 | IAPCommandQueue m_ObservableQueue; 33 | SKPaymentTransactionObserver* m_Observer; 34 | }; 35 | 36 | IAP g_IAP; 37 | 38 | 39 | // The payload of the purchasing commands 40 | struct IAPProduct 41 | { 42 | const char* ident; 43 | const char* title; 44 | const char* description; 45 | float price; 46 | const char* price_string; 47 | const char* currency_code; 48 | }; 49 | 50 | struct IAPResponse 51 | { 52 | const char* error; // optional 53 | int32_t error_code; // only valid if error is set 54 | dmArray m_Products; 55 | 56 | IAPResponse() { 57 | memset(this, 0, sizeof(*this)); 58 | } 59 | }; 60 | 61 | struct IAPTransaction 62 | { 63 | const char* ident; 64 | int32_t state; 65 | const char* date; 66 | const char* trans_ident; // optional 67 | const char* receipt; // optional 68 | uint32_t receipt_length; 69 | const char* original_trans; // optional 70 | const char* error; // optional 71 | int32_t error_code; // only valid if error is set 72 | IAPTransaction* m_OriginalTransaction; 73 | 74 | IAPTransaction() { 75 | memset(this, 0, sizeof(*this)); 76 | } 77 | }; 78 | 79 | static void IAP_FreeProduct(IAPProduct* product) 80 | { 81 | free((void*)product->ident); 82 | free((void*)product->title); 83 | free((void*)product->description); 84 | free((void*)product->price_string); 85 | free((void*)product->currency_code); 86 | } 87 | 88 | static void IAP_FreeTransaction(IAPTransaction* transaction) 89 | { 90 | if (transaction->m_OriginalTransaction) { 91 | IAP_FreeTransaction(transaction->m_OriginalTransaction); 92 | } 93 | delete transaction->m_OriginalTransaction; 94 | free((void*)transaction->ident); 95 | free((void*)transaction->date); 96 | free((void*)transaction->trans_ident); 97 | free((void*)transaction->receipt); 98 | free((void*)transaction->original_trans); 99 | free((void*)transaction->error); 100 | } 101 | 102 | 103 | @interface SKProductsRequestDelegate : NSObject 104 | @property dmScript::LuaCallbackInfo* m_Callback; 105 | @property (assign) SKProductsRequest* m_Request; 106 | @property int m_Version; 107 | @end 108 | 109 | @implementation SKProductsRequestDelegate 110 | - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{ 111 | 112 | if (self.m_Version != g_IAP.m_Version) { 113 | dmLogWarning("Received products but the extension has been restarted") 114 | return; 115 | } 116 | 117 | if (!dmScript::IsCallbackValid(self.m_Callback)) { 118 | dmLogError("No callback set"); 119 | return; 120 | } 121 | 122 | NSArray* skProducts = response.products; 123 | 124 | IAPResponse* iap_response = new IAPResponse; 125 | for (SKProduct * p in skProducts) { 126 | 127 | IAPProduct product = {0}; 128 | product.ident = strdup([p.productIdentifier UTF8String]); 129 | if (p.localizedTitle) { 130 | product.title = strdup([p.localizedTitle UTF8String]); 131 | } 132 | else { 133 | dmLogWarning("Product %s has no localizedTitle", [p.productIdentifier UTF8String]); 134 | product.title = strdup(""); 135 | } 136 | if (p.localizedDescription) { 137 | product.description = strdup([p.localizedDescription UTF8String]); 138 | } 139 | else { 140 | dmLogWarning("Product %s has no localizedDescription", [p.productIdentifier UTF8String]); 141 | product.description = strdup(""); 142 | } 143 | product.currency_code = strdup([[p.priceLocale objectForKey:NSLocaleCurrencyCode] UTF8String]); 144 | product.price = p.price.floatValue; 145 | 146 | NSNumberFormatter *formatter = [[[NSNumberFormatter alloc] init] autorelease]; 147 | [formatter setNumberStyle: NSNumberFormatterCurrencyStyle]; 148 | [formatter setLocale: p.priceLocale]; 149 | NSString* price_string = [formatter stringFromNumber: p.price]; 150 | product.price_string = strdup([price_string UTF8String]); 151 | 152 | if (iap_response->m_Products.Full()) { 153 | iap_response->m_Products.OffsetCapacity(2); 154 | } 155 | iap_response->m_Products.Push(product); 156 | } 157 | 158 | 159 | IAPCommand cmd; 160 | cmd.m_Command = IAP_PRODUCT_RESULT; 161 | cmd.m_Callback = self.m_Callback; 162 | cmd.m_Data = iap_response; 163 | IAP_Queue_Push(&g_IAP.m_CommandQueue, &cmd); 164 | } 165 | 166 | static void HandleProductResult(IAPCommand* cmd) 167 | { 168 | 169 | IAPResponse* response = (IAPResponse*)cmd->m_Data; 170 | 171 | lua_State* L = dmScript::GetCallbackLuaContext(cmd->m_Callback); 172 | int top = lua_gettop(L); 173 | 174 | if (!dmScript::SetupCallback(cmd->m_Callback)) 175 | { 176 | assert(top == lua_gettop(L)); 177 | delete response; 178 | return; 179 | } 180 | 181 | lua_newtable(L); 182 | 183 | for (uint32_t i = 0; i < response->m_Products.Size(); ++i) { 184 | IAPProduct* product = &response->m_Products[i]; 185 | 186 | lua_pushstring(L, product->ident); 187 | lua_newtable(L); 188 | 189 | lua_pushstring(L, product->ident); 190 | lua_setfield(L, -2, "ident"); 191 | lua_pushstring(L, product->title); 192 | lua_setfield(L, -2, "title"); 193 | lua_pushstring(L, product->description); 194 | lua_setfield(L, -2, "description"); 195 | lua_pushnumber(L, product->price); 196 | lua_setfield(L, -2, "price"); 197 | lua_pushstring(L, product->price_string); 198 | lua_setfield(L, -2, "price_string"); 199 | lua_pushstring(L, product->currency_code); 200 | lua_setfield(L, -2, "currency_code"); 201 | 202 | lua_rawset(L, -3); 203 | 204 | IAP_FreeProduct(product); 205 | } 206 | lua_pushnil(L); 207 | 208 | dmScript::PCall(L, 3, 0); 209 | 210 | dmScript::TeardownCallback(cmd->m_Callback); 211 | dmScript::DestroyCallback(cmd->m_Callback); 212 | 213 | delete response; 214 | assert(top == lua_gettop(L)); 215 | } 216 | 217 | - (void)request:(SKRequest *)request didFailWithError:(NSError *)error{ 218 | dmLogWarning("SKProductsRequest failed: %s", [error.localizedDescription UTF8String]); 219 | 220 | if (!dmScript::IsCallbackValid(self.m_Callback)) { 221 | dmLogError("No callback set"); 222 | return; 223 | } 224 | 225 | IAPResponse* response = new IAPResponse; 226 | response->error = strdup([error.localizedDescription UTF8String]); 227 | response->error_code = REASON_UNSPECIFIED; 228 | 229 | IAPCommand cmd; 230 | cmd.m_Command = IAP_PRODUCT_RESULT; 231 | cmd.m_Callback = self.m_Callback; 232 | cmd.m_Data = response; 233 | 234 | IAP_Queue_Push(&g_IAP.m_CommandQueue, &cmd); 235 | } 236 | 237 | - (void)requestDidFinish:(SKRequest *)request 238 | { 239 | [self.m_Request release]; 240 | [self release]; 241 | } 242 | 243 | @end 244 | 245 | static void CopyTransaction(SKPaymentTransaction* transaction, IAPTransaction* out) 246 | { 247 | if (transaction.transactionState == SKPaymentTransactionStateFailed) { 248 | out->error = strdup([transaction.error.localizedDescription UTF8String]); 249 | out->error_code = transaction.error.code == SKErrorPaymentCancelled ? REASON_USER_CANCELED : REASON_UNSPECIFIED; 250 | } else { 251 | out->error = 0; 252 | } 253 | 254 | NSDateFormatter *dateFormatter = [[[NSDateFormatter alloc] init] autorelease]; 255 | [dateFormatter setLocale: [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]]; 256 | [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZ"]; 257 | 258 | out->ident = strdup([transaction.payment.productIdentifier UTF8String]); 259 | 260 | 261 | if (transaction.transactionDate) 262 | out->date = strdup([[dateFormatter stringFromDate: transaction.transactionDate] UTF8String]); 263 | out->state = transaction.transactionState; 264 | 265 | if (transaction.transactionState == SKPaymentTransactionStatePurchased || 266 | transaction.transactionState == SKPaymentTransactionStateRestored) { 267 | out->trans_ident = strdup([transaction.transactionIdentifier UTF8String]); 268 | } 269 | 270 | if (transaction.transactionState == SKPaymentTransactionStatePurchased) { 271 | NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; 272 | NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL]; 273 | out->receipt_length = receiptData.length; 274 | out->receipt = (const char*)malloc(out->receipt_length); 275 | memcpy((void*)out->receipt, receiptData.bytes, out->receipt_length); 276 | } 277 | 278 | if (transaction.transactionState == SKPaymentTransactionStateRestored && transaction.originalTransaction) { 279 | out->m_OriginalTransaction = new IAPTransaction; 280 | CopyTransaction(transaction.originalTransaction, out->m_OriginalTransaction); 281 | } 282 | } 283 | 284 | static void PushTransaction(lua_State* L, IAPTransaction* transaction) 285 | { 286 | // first argument to the callback 287 | lua_newtable(L); 288 | 289 | lua_pushstring(L, transaction->ident); 290 | lua_setfield(L, -2, "ident"); 291 | lua_pushnumber(L, transaction->state); 292 | lua_setfield(L, -2, "state"); 293 | if (transaction->date) { 294 | lua_pushstring(L, transaction->date); 295 | lua_setfield(L, -2, "date"); 296 | } 297 | 298 | if (transaction->trans_ident) { 299 | lua_pushstring(L, transaction->trans_ident); 300 | lua_setfield(L, -2, "trans_ident"); 301 | } 302 | if (transaction->receipt) { 303 | lua_pushlstring(L, transaction->receipt, transaction->receipt_length); 304 | lua_setfield(L, -2, "receipt"); 305 | } 306 | 307 | if (transaction->m_OriginalTransaction) { 308 | lua_pushstring(L, "original_trans"); 309 | PushTransaction(L, transaction->m_OriginalTransaction); 310 | lua_rawset(L, -3); 311 | } 312 | } 313 | 314 | 315 | static void HandlePurchaseResult(IAPCommand* cmd) 316 | { 317 | IAPTransaction* transaction = (IAPTransaction*)cmd->m_Data; 318 | 319 | lua_State* L = dmScript::GetCallbackLuaContext(cmd->m_Callback); 320 | int top = lua_gettop(L); 321 | 322 | if (!dmScript::SetupCallback(cmd->m_Callback)) 323 | { 324 | assert(top == lua_gettop(L)); 325 | return; 326 | } 327 | 328 | PushTransaction(L, transaction); 329 | 330 | // Second argument to callback 331 | if (transaction->error) { 332 | IAP_PushError(L, transaction->error, transaction->error_code); 333 | } else { 334 | lua_pushnil(L); 335 | } 336 | 337 | dmScript::PCall(L, 3, 0); 338 | 339 | dmScript::TeardownCallback(cmd->m_Callback); 340 | 341 | IAP_FreeTransaction(transaction); 342 | 343 | delete transaction; 344 | assert(top == lua_gettop(L)); 345 | } 346 | 347 | static void processTransactions(IAP* iap, NSArray* transactions) { 348 | for (SKPaymentTransaction* transaction in transactions) { 349 | if ((!iap->m_AutoFinishTransactions) && (transaction.transactionState == SKPaymentTransactionStatePurchased)) { 350 | NSData *data = [transaction.transactionIdentifier dataUsingEncoding:NSUTF8StringEncoding]; 351 | uint64_t trans_id_hash = dmHashBuffer64((const char*) [data bytes], [data length]); 352 | [iap->m_PendingTransactions setObject:transaction forKey:[NSNumber numberWithInteger:trans_id_hash] ]; 353 | } 354 | 355 | if (!iap->m_Listener) 356 | continue; 357 | 358 | IAPTransaction* iap_transaction = new IAPTransaction; 359 | CopyTransaction(transaction, iap_transaction); 360 | 361 | IAPCommand cmd; 362 | cmd.m_Callback = iap->m_Listener; 363 | cmd.m_Command = IAP_PURCHASE_RESULT; 364 | cmd.m_Data = iap_transaction; 365 | IAP_Queue_Push(&iap->m_ObservableQueue, &cmd); 366 | 367 | switch (transaction.transactionState) 368 | { 369 | case SKPaymentTransactionStatePurchased: 370 | if (g_IAP.m_AutoFinishTransactions) { 371 | [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; 372 | } 373 | break; 374 | case SKPaymentTransactionStateFailed: 375 | [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; 376 | break; 377 | case SKPaymentTransactionStateRestored: 378 | [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; 379 | break; 380 | default: 381 | break; 382 | } 383 | } 384 | } 385 | 386 | static int IAP_ProcessPendingTransactions(lua_State* L) 387 | { 388 | processTransactions(&g_IAP, [SKPaymentQueue defaultQueue].transactions); 389 | return 0; 390 | } 391 | 392 | @implementation SKPaymentTransactionObserver 393 | - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions 394 | { 395 | processTransactions(self.m_IAP, transactions); 396 | } 397 | @end 398 | 399 | static int IAP_List(lua_State* L) 400 | { 401 | int top = lua_gettop(L); 402 | 403 | NSCountedSet* product_identifiers = [[[NSCountedSet alloc] init] autorelease]; 404 | 405 | luaL_checktype(L, 1, LUA_TTABLE); 406 | lua_pushnil(L); 407 | while (lua_next(L, 1) != 0) { 408 | const char* p = luaL_checkstring(L, -1); 409 | [product_identifiers addObject: [NSString stringWithUTF8String: p]]; 410 | lua_pop(L, 1); 411 | } 412 | 413 | SKProductsRequest* products_request = [[SKProductsRequest alloc] initWithProductIdentifiers: product_identifiers]; 414 | SKProductsRequestDelegate* delegate = [SKProductsRequestDelegate alloc]; 415 | 416 | delegate.m_Callback = dmScript::CreateCallback(L, 2); 417 | delegate.m_Request = products_request; 418 | delegate.m_Version = g_IAP.m_Version; 419 | products_request.delegate = delegate; 420 | [products_request start]; 421 | 422 | assert(top == lua_gettop(L)); 423 | return 0; 424 | } 425 | 426 | static int IAP_Buy(lua_State* L) 427 | { 428 | int top = lua_gettop(L); 429 | 430 | const char* id = luaL_checkstring(L, 1); 431 | SKMutablePayment* payment = [[SKMutablePayment alloc] init]; 432 | payment.productIdentifier = [NSString stringWithUTF8String: id]; 433 | payment.quantity = 1; 434 | 435 | [[SKPaymentQueue defaultQueue] addPayment:payment]; 436 | [payment release]; 437 | 438 | assert(top == lua_gettop(L)); 439 | return 0; 440 | } 441 | 442 | static int IAP_Finish(lua_State* L) 443 | { 444 | if(g_IAP.m_AutoFinishTransactions) 445 | { 446 | dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored."); 447 | return 0; 448 | } 449 | 450 | int top = lua_gettop(L); 451 | 452 | luaL_checktype(L, 1, LUA_TTABLE); 453 | 454 | lua_getfield(L, -1, "state"); 455 | if (lua_isnumber(L, -1)) 456 | { 457 | if(lua_tointeger(L, -1) != SKPaymentTransactionStatePurchased) 458 | { 459 | dmLogError("Transaction error. Invalid transaction state for transaction finish (must be iap.TRANS_STATE_PURCHASED)."); 460 | lua_pop(L, 1); 461 | assert(top == lua_gettop(L)); 462 | return 0; 463 | } 464 | } 465 | lua_pop(L, 1); 466 | 467 | lua_getfield(L, -1, "trans_ident"); 468 | if (!lua_isstring(L, -1)) { 469 | dmLogError("Transaction error. Invalid transaction data for transaction finish, does not contain 'trans_ident' key."); 470 | lua_pop(L, 1); 471 | } 472 | else 473 | { 474 | const char *str = lua_tostring(L, -1); 475 | uint64_t trans_ident_hash = dmHashBuffer64(str, strlen(str)); 476 | lua_pop(L, 1); 477 | SKPaymentTransaction * transaction = [g_IAP.m_PendingTransactions objectForKey:[NSNumber numberWithInteger:trans_ident_hash]]; 478 | if(transaction == 0x0) { 479 | dmLogError("Transaction error. Invalid trans_ident value for transaction finish."); 480 | } else { 481 | [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; 482 | [g_IAP.m_PendingTransactions removeObjectForKey:[NSNumber numberWithInteger:trans_ident_hash]]; 483 | } 484 | } 485 | 486 | assert(top == lua_gettop(L)); 487 | return 0; 488 | } 489 | 490 | static int IAP_Restore(lua_State* L) 491 | { 492 | // TODO: Missing callback here for completion/error 493 | // See callback under "Handling Restored Transactions" 494 | // https://developer.apple.com/library/ios/documentation/StoreKit/Reference/SKPaymentTransactionObserver_Protocol/Reference/Reference.html 495 | int top = lua_gettop(L); 496 | [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; 497 | assert(top == lua_gettop(L)); 498 | lua_pushboolean(L, 1); 499 | return 1; 500 | } 501 | 502 | static int IAP_SetListener(lua_State* L) 503 | { 504 | IAP* iap = &g_IAP; 505 | 506 | if (iap->m_Listener) 507 | dmScript::DestroyCallback(iap->m_Listener); 508 | 509 | iap->m_Listener = dmScript::CreateCallback(L, 1); 510 | return 0; 511 | } 512 | 513 | static int IAP_Acknowledge(lua_State* L) 514 | { 515 | return 0; 516 | } 517 | 518 | static int IAP_GetProviderId(lua_State* L) 519 | { 520 | lua_pushinteger(L, PROVIDER_ID_APPLE); 521 | return 1; 522 | } 523 | 524 | static const luaL_reg IAP_methods[] = 525 | { 526 | {"list", IAP_List}, 527 | {"buy", IAP_Buy}, 528 | {"finish", IAP_Finish}, 529 | {"acknowledge", IAP_Acknowledge}, 530 | {"restore", IAP_Restore}, 531 | {"set_listener", IAP_SetListener}, 532 | {"get_provider_id", IAP_GetProviderId}, 533 | {"process_pending_transactions", IAP_ProcessPendingTransactions}, 534 | {0, 0} 535 | }; 536 | 537 | static dmExtension::Result InitializeIAP(dmExtension::Params* params) 538 | { 539 | g_IAP.m_AutoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; 540 | g_IAP.m_PendingTransactions = [[NSMutableDictionary alloc]initWithCapacity:2]; 541 | g_IAP.m_Version++; 542 | 543 | IAP_Queue_Create(&g_IAP.m_CommandQueue); 544 | IAP_Queue_Create(&g_IAP.m_ObservableQueue); 545 | 546 | lua_State*L = params->m_L; 547 | int top = lua_gettop(L); 548 | luaL_register(L, LIB_NAME, IAP_methods); 549 | 550 | // ensure ios payment constants values corresponds to iap constants. 551 | assert((int)TRANS_STATE_PURCHASING == (int)SKPaymentTransactionStatePurchasing); 552 | assert((int)TRANS_STATE_PURCHASED == (int)SKPaymentTransactionStatePurchased); 553 | assert((int)TRANS_STATE_FAILED == (int)SKPaymentTransactionStateFailed); 554 | assert((int)TRANS_STATE_RESTORED == (int)SKPaymentTransactionStateRestored); 555 | 556 | IAP_PushConstants(L); 557 | 558 | lua_pop(L, 1); 559 | assert(top == lua_gettop(L)); 560 | 561 | SKPaymentTransactionObserver* observer = [[SKPaymentTransactionObserver alloc] init]; 562 | observer.m_IAP = &g_IAP; 563 | [[SKPaymentQueue defaultQueue] addTransactionObserver: observer]; 564 | g_IAP.m_Observer = observer; 565 | 566 | return dmExtension::RESULT_OK; 567 | } 568 | 569 | static void IAP_OnCommand(IAPCommand* cmd, void*) 570 | { 571 | switch (cmd->m_Command) 572 | { 573 | case IAP_PRODUCT_RESULT: 574 | HandleProductResult(cmd); 575 | break; 576 | case IAP_PURCHASE_RESULT: 577 | HandlePurchaseResult(cmd); 578 | break; 579 | 580 | default: 581 | assert(false); 582 | } 583 | } 584 | 585 | static dmExtension::Result UpdateIAP(dmExtension::Params* params) 586 | { 587 | IAP_Queue_Flush(&g_IAP.m_CommandQueue, IAP_OnCommand, 0); 588 | if (g_IAP.m_Observer) { 589 | IAP_Queue_Flush(&g_IAP.m_ObservableQueue, IAP_OnCommand, 0); 590 | } 591 | return dmExtension::RESULT_OK; 592 | } 593 | 594 | static dmExtension::Result FinalizeIAP(dmExtension::Params* params) 595 | { 596 | if (g_IAP.m_Listener) 597 | { 598 | dmScript::DestroyCallback(g_IAP.m_Listener); 599 | } 600 | 601 | g_IAP.m_Listener = 0; 602 | 603 | if (g_IAP.m_PendingTransactions) { 604 | [g_IAP.m_PendingTransactions release]; 605 | g_IAP.m_PendingTransactions = 0; 606 | } 607 | 608 | if (g_IAP.m_Observer) { 609 | [[SKPaymentQueue defaultQueue] removeTransactionObserver: g_IAP.m_Observer]; 610 | [g_IAP.m_Observer release]; 611 | g_IAP.m_Observer = 0; 612 | } 613 | 614 | IAP_Queue_Destroy(&g_IAP.m_CommandQueue); 615 | IAP_Queue_Destroy(&g_IAP.m_ObservableQueue); 616 | 617 | return dmExtension::RESULT_OK; 618 | } 619 | 620 | 621 | DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, UpdateIAP, 0, FinalizeIAP) 622 | 623 | #endif // DM_PLATFORM_IOS 624 | -------------------------------------------------------------------------------- /extension-iap/src/iap_null.cpp: -------------------------------------------------------------------------------- 1 | #if !defined(DM_PLATFORM_HTML5) && !defined(DM_PLATFORM_ANDROID) && !defined(DM_PLATFORM_IOS) 2 | 3 | extern "C" void IAPExt() 4 | { 5 | 6 | } 7 | 8 | #endif // !DM_PLATFORM_HTML5 && !DM_PLATFORM_ANDROID && !DM_PLATFORM_IOS 9 | 10 | -------------------------------------------------------------------------------- /extension-iap/src/iap_private.cpp: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) 2 | 3 | #include 4 | 5 | #include "iap.h" 6 | #include "iap_private.h" 7 | #include 8 | #include 9 | 10 | // Creates a comma separated string, given a table where all values are strings (or numbers) 11 | // Returns a malloc'ed string, which the caller must free 12 | char* IAP_List_CreateBuffer(lua_State* L) 13 | { 14 | int top = lua_gettop(L); 15 | 16 | luaL_checktype(L, 1, LUA_TTABLE); 17 | lua_pushnil(L); 18 | int length = 0; 19 | while (lua_next(L, 1) != 0) { 20 | if (length > 0) { 21 | ++length; 22 | } 23 | const char* p = lua_tostring(L, -1); 24 | if(!p) 25 | { 26 | luaL_error(L, "IAP: Failed to get value (string) from table"); 27 | } 28 | length += strlen(p); 29 | lua_pop(L, 1); 30 | } 31 | 32 | char* buf = (char*)malloc(length+1); 33 | if( buf == 0 ) 34 | { 35 | dmLogError("Could not allocate buffer of size %d", length+1); 36 | assert(top == lua_gettop(L)); 37 | return 0; 38 | } 39 | buf[0] = '\0'; 40 | 41 | int i = 0; 42 | lua_pushnil(L); 43 | while (lua_next(L, 1) != 0) { 44 | if (i > 0) { 45 | dmStrlCat(buf, ",", length+1); 46 | } 47 | const char* p = lua_tostring(L, -1); 48 | if(!p) 49 | { 50 | luaL_error(L, "IAP: Failed to get value (string) from table"); 51 | } 52 | dmStrlCat(buf, p, length+1); 53 | lua_pop(L, 1); 54 | ++i; 55 | } 56 | 57 | assert(top == lua_gettop(L)); 58 | return buf; 59 | } 60 | 61 | void IAP_PushError(lua_State* L, const char* error, int reason) 62 | { 63 | if (error != 0) { 64 | lua_newtable(L); 65 | lua_pushstring(L, "error"); 66 | lua_pushstring(L, error); 67 | lua_rawset(L, -3); 68 | lua_pushstring(L, "reason"); 69 | lua_pushnumber(L, reason); 70 | lua_rawset(L, -3); 71 | } else { 72 | lua_pushnil(L); 73 | } 74 | } 75 | 76 | void IAP_PushConstants(lua_State* L) 77 | { 78 | #define SETCONSTANT(name) \ 79 | lua_pushnumber(L, (lua_Number) name); \ 80 | lua_setfield(L, -2, #name);\ 81 | 82 | SETCONSTANT(TRANS_STATE_PURCHASING) 83 | SETCONSTANT(TRANS_STATE_PURCHASED) 84 | SETCONSTANT(TRANS_STATE_FAILED) 85 | SETCONSTANT(TRANS_STATE_RESTORED) 86 | SETCONSTANT(TRANS_STATE_UNVERIFIED) 87 | 88 | SETCONSTANT(REASON_UNSPECIFIED) 89 | SETCONSTANT(REASON_USER_CANCELED) 90 | 91 | SETCONSTANT(PROVIDER_ID_GOOGLE) 92 | SETCONSTANT(PROVIDER_ID_AMAZON) 93 | SETCONSTANT(PROVIDER_ID_APPLE) 94 | SETCONSTANT(PROVIDER_ID_FACEBOOK) 95 | 96 | #undef SETCONSTANT 97 | } 98 | 99 | 100 | void IAP_Queue_Create(IAPCommandQueue* queue) 101 | { 102 | queue->m_Mutex = dmMutex::New(); 103 | } 104 | 105 | void IAP_Queue_Destroy(IAPCommandQueue* queue) 106 | { 107 | dmMutex::Delete(queue->m_Mutex); 108 | } 109 | 110 | void IAP_Queue_Push(IAPCommandQueue* queue, IAPCommand* cmd) 111 | { 112 | DM_MUTEX_SCOPED_LOCK(queue->m_Mutex); 113 | 114 | if(queue->m_Commands.Full()) 115 | { 116 | queue->m_Commands.OffsetCapacity(2); 117 | } 118 | queue->m_Commands.Push(*cmd); 119 | } 120 | 121 | void IAP_Queue_Flush(IAPCommandQueue* queue, IAPCommandFn fn, void* ctx) 122 | { 123 | assert(fn != 0); 124 | 125 | if (queue->m_Commands.Empty()) 126 | { 127 | return; 128 | } 129 | 130 | dmArray tmp; 131 | { 132 | DM_MUTEX_SCOPED_LOCK(queue->m_Mutex); 133 | tmp.Swap(queue->m_Commands); 134 | } 135 | 136 | for(uint32_t i = 0; i != tmp.Size(); ++i) 137 | { 138 | fn(&tmp[i], ctx); 139 | } 140 | } 141 | 142 | #endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS 143 | -------------------------------------------------------------------------------- /extension-iap/src/iap_private.h: -------------------------------------------------------------------------------- 1 | #if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) 2 | 3 | #ifndef IAP_PRIVATE_H 4 | #define IAP_PRIVATE_H 5 | 6 | #include 7 | 8 | enum EIAPCommand 9 | { 10 | IAP_PRODUCT_RESULT, 11 | IAP_PURCHASE_RESULT, 12 | }; 13 | 14 | struct DM_ALIGNED(16) IAPCommand 15 | { 16 | IAPCommand() 17 | { 18 | memset(this, 0, sizeof(IAPCommand)); 19 | } 20 | 21 | // Used for storing eventual callback info (if needed) 22 | dmScript::LuaCallbackInfo* m_Callback; 23 | 24 | // The actual command payload 25 | int32_t m_Command; 26 | int32_t m_ResponseCode; 27 | void* m_Data; 28 | }; 29 | 30 | struct IAPCommandQueue 31 | { 32 | dmArray m_Commands; 33 | dmMutex::HMutex m_Mutex; 34 | }; 35 | 36 | char* IAP_List_CreateBuffer(lua_State* L); 37 | void IAP_PushError(lua_State* L, const char* error, int reason); 38 | void IAP_PushConstants(lua_State* L); 39 | 40 | typedef void (*IAPCommandFn)(IAPCommand* cmd, void* ctx); 41 | 42 | void IAP_Queue_Create(IAPCommandQueue* queue); 43 | void IAP_Queue_Destroy(IAPCommandQueue* queue); 44 | // The command is copied by value into the queue 45 | void IAP_Queue_Push(IAPCommandQueue* queue, IAPCommand* cmd); 46 | void IAP_Queue_Flush(IAPCommandQueue* queue, IAPCommandFn fn, void* ctx); 47 | 48 | #endif 49 | 50 | #endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS 51 | -------------------------------------------------------------------------------- /extension-iap/src/java/com/defold/iap/IListProductsListener.java: -------------------------------------------------------------------------------- 1 | package com.defold.iap; 2 | 3 | public interface IListProductsListener { 4 | public void onProductsResult(int resultCode, String productList, long cmdHandle); 5 | } 6 | -------------------------------------------------------------------------------- /extension-iap/src/java/com/defold/iap/IPurchaseListener.java: -------------------------------------------------------------------------------- 1 | package com.defold.iap; 2 | 3 | public interface IPurchaseListener { 4 | public void onPurchaseResult(int responseCode, String purchaseData); 5 | } 6 | -------------------------------------------------------------------------------- /extension-iap/src/java/com/defold/iap/IapAmazon.java: -------------------------------------------------------------------------------- 1 | package com.defold.iap; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.Map; 5 | import java.util.Set; 6 | import java.util.HashMap; 7 | import java.util.HashSet; 8 | import java.util.Date; 9 | import java.util.concurrent.ArrayBlockingQueue; 10 | import java.util.concurrent.BlockingQueue; 11 | 12 | import org.json.JSONException; 13 | import org.json.JSONObject; 14 | import org.json.JSONArray; 15 | 16 | import android.app.Activity; 17 | import android.os.Bundle; 18 | import android.os.IBinder; 19 | import android.os.RemoteException; 20 | import android.util.Log; 21 | 22 | import com.amazon.device.iap.PurchasingService; 23 | import com.amazon.device.iap.PurchasingListener; 24 | import com.amazon.device.iap.model.ProductDataResponse; 25 | import com.amazon.device.iap.model.PurchaseUpdatesResponse; 26 | import com.amazon.device.iap.model.PurchaseResponse; 27 | import com.amazon.device.iap.model.UserDataResponse; 28 | import com.amazon.device.iap.model.RequestId; 29 | import com.amazon.device.iap.model.Product; 30 | import com.amazon.device.iap.model.Receipt; 31 | import com.amazon.device.iap.model.UserData; 32 | import com.amazon.device.iap.model.FulfillmentResult; 33 | 34 | public class IapAmazon implements PurchasingListener { 35 | 36 | public static final String TAG = "iap"; 37 | 38 | private HashMap listProductsListeners; 39 | private HashMap listProductsCommandPtrs; 40 | private HashMap purchaseListeners; 41 | 42 | private Activity activity; 43 | private boolean autoFinishTransactions; 44 | 45 | public IapAmazon(Activity activity, boolean autoFinishTransactions) { 46 | this.activity = activity; 47 | this.autoFinishTransactions = autoFinishTransactions; 48 | this.listProductsListeners = new HashMap(); 49 | this.listProductsCommandPtrs = new HashMap(); 50 | this.purchaseListeners = new HashMap(); 51 | PurchasingService.registerListener(activity, this); 52 | } 53 | 54 | private void init() { 55 | } 56 | 57 | public void stop() { 58 | } 59 | 60 | public void listItems(final String skus, final IListProductsListener listener, final long commandPtr) { 61 | final Set skuSet = new HashSet(); 62 | for (String x : skus.split(",")) { 63 | if (x.trim().length() > 0) { 64 | if (!skuSet.contains(x)) { 65 | skuSet.add(x); 66 | } 67 | } 68 | } 69 | 70 | // It might seem unconventional to hold the lock while doing the function call, 71 | // but it prevents a race condition, as the API does not allow supplying own 72 | // requestId which could be generated ahead of time. 73 | synchronized (listProductsListeners) { 74 | RequestId req = PurchasingService.getProductData(skuSet); 75 | if (req != null) { 76 | listProductsListeners.put(req, listener); 77 | listProductsCommandPtrs.put(req, commandPtr); 78 | } else { 79 | Log.e(TAG, "Did not expect a null requestId"); 80 | } 81 | } 82 | } 83 | 84 | public void buy(final String product, final String token, final IPurchaseListener listener) { 85 | synchronized (purchaseListeners) { 86 | RequestId req = PurchasingService.purchase(product); 87 | if (req != null) { 88 | purchaseListeners.put(req, listener); 89 | } else { 90 | Log.e(TAG, "Did not expect a null requestId"); 91 | } 92 | } 93 | } 94 | 95 | public void finishTransaction(final String receipt, final IPurchaseListener listener) { 96 | if(this.autoFinishTransactions) { 97 | return; 98 | } 99 | PurchasingService.notifyFulfillment(receipt, FulfillmentResult.FULFILLED); 100 | } 101 | 102 | public void acknowledgeTransaction(final String purchaseToken, final IPurchaseListener purchaseListener) { 103 | // Stub to prevent errors. 104 | } 105 | 106 | private void doGetPurchaseUpdates(final IPurchaseListener listener, final boolean reset) { 107 | synchronized (purchaseListeners) { 108 | RequestId req = PurchasingService.getPurchaseUpdates(reset); 109 | if (req != null) { 110 | purchaseListeners.put(req, listener); 111 | } else { 112 | Log.e(TAG, "Did not expect a null requestId"); 113 | } 114 | } 115 | } 116 | 117 | public void processPendingConsumables(final IPurchaseListener listener) { 118 | // reset = false means getting any new receipts since the last call. 119 | doGetPurchaseUpdates(listener, false); 120 | } 121 | 122 | public void restore(final IPurchaseListener listener) { 123 | // reset = true means getting all transaction history, although consumables 124 | // are not included, only entitlements, after testing. 125 | doGetPurchaseUpdates(listener, true); 126 | } 127 | 128 | public static String toISO8601(final Date date) { 129 | String formatted = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").format(date); 130 | return formatted.substring(0, 22) + ":" + formatted.substring(22); 131 | } 132 | 133 | private JSONObject makeTransactionObject(final UserData user, final Receipt receipt, int state) throws JSONException { 134 | JSONObject transaction = new JSONObject(); 135 | transaction.put("ident", receipt.getSku()); 136 | transaction.put("state", state); 137 | transaction.put("date", toISO8601(receipt.getPurchaseDate())); 138 | transaction.put("trans_ident", receipt.getReceiptId()); 139 | transaction.put("receipt", receipt.getReceiptId()); 140 | 141 | // Only for amazon (this far), but required for using their server side receipt validation. 142 | transaction.put("is_sandbox_mode", PurchasingService.IS_SANDBOX_MODE); 143 | transaction.put("user_id", user.getUserId()); 144 | 145 | // According to documentation, cancellation support has to be enabled per item, and this is 146 | // not officially supported by any other IAP provider, and it is not expected to be used here either. 147 | // 148 | // But enforcing the use of only non-cancelable items is not possible either; so include these flags 149 | // for completeness. 150 | if (receipt.getCancelDate() != null) 151 | transaction.put("cancel_date", toISO8601(receipt.getCancelDate())); 152 | transaction.put("canceled", receipt.isCanceled()); 153 | return transaction; 154 | } 155 | 156 | // This callback method is invoked when an ProductDataResponse is available for a request initiated by PurchasingService.getProductData(java.util.Set). 157 | @Override 158 | public void onProductDataResponse(ProductDataResponse productDataResponse) { 159 | RequestId reqId = productDataResponse.getRequestId(); 160 | IListProductsListener listener; 161 | long commadPtr = 0; 162 | synchronized (this.listProductsListeners) { 163 | listener = this.listProductsListeners.get(reqId); 164 | commadPtr = this.listProductsCommandPtrs.get(reqId); 165 | 166 | this.listProductsListeners.remove(reqId); 167 | this.listProductsCommandPtrs.remove(reqId); 168 | if (listener == null) { 169 | Log.e(TAG, "No listener found for request " + reqId.toString()); 170 | return; 171 | } 172 | } 173 | 174 | if (productDataResponse.getRequestStatus() != ProductDataResponse.RequestStatus.SUCCESSFUL) { 175 | listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null, commadPtr); 176 | } else { 177 | for (final String s : productDataResponse.getUnavailableSkus()) { 178 | Log.v(TAG, "Unavailable SKU: " + s); 179 | } 180 | 181 | Map products = productDataResponse.getProductData(); 182 | try { 183 | JSONArray data = new JSONArray(); 184 | for (Map.Entry entry : products.entrySet()) { 185 | String key = entry.getKey(); 186 | Product product = entry.getValue(); 187 | JSONObject item = new JSONObject(); 188 | item.put("ident", product.getSku()); 189 | item.put("title", product.getTitle()); 190 | item.put("description", product.getDescription()); 191 | if (product.getPrice() != null) { 192 | String priceString = product.getPrice(); 193 | item.put("price_string", priceString); 194 | // Based on return values from getPrice: https://developer.amazon.com/public/binaries/content/assets/javadoc/in-app-purchasing-api/com/amazon/inapp/purchasing/item.html 195 | item.put("price", priceString.replaceAll("[^0-9.,]", "")); 196 | } 197 | data.put(item); 198 | } 199 | listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_OK, data.toString(), commadPtr); 200 | } catch (JSONException e) { 201 | listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null, commadPtr); 202 | } 203 | } 204 | } 205 | 206 | // Convenience function for getting and removing a purchaseListener (used for more than one operation). 207 | private IPurchaseListener pickPurchaseListener(RequestId requestId) { 208 | synchronized (this.purchaseListeners) { 209 | IPurchaseListener listener = this.purchaseListeners.get(requestId); 210 | if (listener != null) { 211 | this.purchaseListeners.remove(requestId); 212 | return listener; 213 | } 214 | } 215 | return null; 216 | } 217 | 218 | // This callback method is invoked when a PurchaseResponse is available for a purchase initiated by PurchasingService.purchase(String). 219 | @Override 220 | public void onPurchaseResponse(PurchaseResponse purchaseResponse) { 221 | 222 | IPurchaseListener listener = pickPurchaseListener(purchaseResponse.getRequestId()); 223 | if (listener == null) { 224 | Log.e(TAG, "No listener found for request: " + purchaseResponse.getRequestId().toString()); 225 | return; 226 | } 227 | 228 | int code; 229 | String data = null; 230 | String fulfilReceiptId = null; 231 | 232 | switch (purchaseResponse.getRequestStatus()) { 233 | case SUCCESSFUL: 234 | { 235 | try { 236 | code = IapJNI.BILLING_RESPONSE_RESULT_OK; 237 | data = makeTransactionObject(purchaseResponse.getUserData(), purchaseResponse.getReceipt(), IapJNI.TRANS_STATE_PURCHASED).toString(); 238 | fulfilReceiptId = purchaseResponse.getReceipt().getReceiptId(); 239 | } catch (JSONException e) { 240 | Log.e(TAG, "JSON Exception occured: " + e.toString()); 241 | code = IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR; 242 | } 243 | } 244 | break; 245 | case ALREADY_PURCHASED: 246 | code = IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED; 247 | break; 248 | case INVALID_SKU: 249 | code = IapJNI.BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE; 250 | break; 251 | case FAILED: 252 | case NOT_SUPPORTED: 253 | default: 254 | code = IapJNI.BILLING_RESPONSE_RESULT_ERROR; 255 | break; 256 | } 257 | 258 | listener.onPurchaseResult(code, data); 259 | 260 | if (fulfilReceiptId != null && autoFinishTransactions) { 261 | PurchasingService.notifyFulfillment(fulfilReceiptId, FulfillmentResult.FULFILLED); 262 | } 263 | } 264 | 265 | // This callback method is invoked when a PurchaseUpdatesResponse is available for a request initiated by PurchasingService.getPurchaseUpdates(boolean). 266 | @Override 267 | public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse purchaseUpdatesResponse) { 268 | 269 | // The documentation seems to be a little misguiding regarding how to handle this. 270 | // This call is in response to getPurchaseUpdates() which can be called in two modes 271 | // 272 | // 1) Get all receipts since last call (reset = true) 273 | // 2) Get the whole transaction history. 274 | // 275 | // The result can carry the flag hasMore() where it is required to call getPurchaseUpdates again. See docs: 276 | // https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 277 | // 278 | // Examples indicate it should be called with the same value for 'reset' the secon time around 279 | // but actual testing ends up in an infinite loop where the same results are returned over and over. 280 | // 281 | // So here getPurchaseUpdates is called with result=false to fetch the next round of receipts. 282 | 283 | RequestId reqId = purchaseUpdatesResponse.getRequestId(); 284 | IPurchaseListener listener = pickPurchaseListener(reqId); 285 | if (listener == null) { 286 | Log.e(TAG, "No listener found for request " + reqId.toString()); 287 | return; 288 | } 289 | 290 | switch (purchaseUpdatesResponse.getRequestStatus()) { 291 | case SUCCESSFUL: 292 | { 293 | try { 294 | for (Receipt receipt : purchaseUpdatesResponse.getReceipts()) { 295 | JSONObject trans = makeTransactionObject(purchaseUpdatesResponse.getUserData(), receipt, IapJNI.TRANS_STATE_PURCHASED); 296 | listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_OK, trans.toString()); 297 | if(autoFinishTransactions) { 298 | PurchasingService.notifyFulfillment(receipt.getReceiptId(), FulfillmentResult.FULFILLED); 299 | } 300 | } 301 | if (purchaseUpdatesResponse.hasMore()) { 302 | doGetPurchaseUpdates(listener, false); 303 | } 304 | } catch (JSONException e) { 305 | Log.e(TAG, "JSON Exception occured: " + e.toString()); 306 | listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR, null); 307 | } 308 | } 309 | break; 310 | case FAILED: 311 | case NOT_SUPPORTED: 312 | default: 313 | listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); 314 | break; 315 | } 316 | } 317 | 318 | // This callback method is invoked when a UserDataResponse is available for a request initiated by PurchasingService.getUserData(). 319 | @Override 320 | public void onUserDataResponse(UserDataResponse userDataResponse) { 321 | // Intentionally left un-implemented; not used. 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /extension-iap/src/java/com/defold/iap/IapGooglePlay.java: -------------------------------------------------------------------------------- 1 | package com.defold.iap; 2 | 3 | import java.text.SimpleDateFormat; 4 | import java.util.ArrayList; 5 | import java.util.Date; 6 | import java.util.Iterator; 7 | import java.util.List; 8 | import java.util.Map; 9 | import java.util.HashMap; 10 | import java.util.concurrent.ArrayBlockingQueue; 11 | import java.util.concurrent.BlockingQueue; 12 | 13 | import org.json.JSONException; 14 | import org.json.JSONObject; 15 | import org.json.JSONArray; 16 | 17 | import android.app.Activity; 18 | import android.util.Log; 19 | 20 | import com.android.billingclient.api.BillingClient; 21 | import com.android.billingclient.api.BillingClient.BillingResponseCode; 22 | import com.android.billingclient.api.BillingClient.ProductType; 23 | import com.android.billingclient.api.BillingResult; 24 | import com.android.billingclient.api.PendingPurchasesParams; 25 | import com.android.billingclient.api.Purchase; 26 | import com.android.billingclient.api.Purchase.PurchaseState; 27 | import com.android.billingclient.api.ProductDetails; 28 | import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails; 29 | import com.android.billingclient.api.ProductDetails.PricingPhases; 30 | import com.android.billingclient.api.ProductDetails.PricingPhase; 31 | import com.android.billingclient.api.ProductDetails.RecurrenceMode; 32 | import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails; 33 | import com.android.billingclient.api.ConsumeParams; 34 | import com.android.billingclient.api.BillingFlowParams; 35 | import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams; 36 | import com.android.billingclient.api.QueryPurchasesParams; 37 | import com.android.billingclient.api.QueryProductDetailsParams; 38 | import com.android.billingclient.api.QueryProductDetailsParams.Product; 39 | import com.android.billingclient.api.AcknowledgePurchaseParams; 40 | import com.android.billingclient.api.PurchasesUpdatedListener; 41 | import com.android.billingclient.api.BillingClientStateListener; 42 | import com.android.billingclient.api.ConsumeResponseListener; 43 | import com.android.billingclient.api.PurchasesResponseListener; 44 | import com.android.billingclient.api.ProductDetailsResponseListener; 45 | import com.android.billingclient.api.AcknowledgePurchaseResponseListener; 46 | 47 | public class IapGooglePlay implements PurchasesUpdatedListener { 48 | public static final String TAG = "IapGooglePlay"; 49 | 50 | private Map products = new HashMap(); 51 | private BillingClient billingClient; 52 | private IPurchaseListener purchaseListener; 53 | private boolean autoFinishTransactions; 54 | private Activity activity; 55 | 56 | public IapGooglePlay(Activity activity, boolean autoFinishTransactions) { 57 | this.activity = activity; 58 | this.autoFinishTransactions = autoFinishTransactions; 59 | 60 | PendingPurchasesParams pendingPurchasesParams = PendingPurchasesParams.newBuilder().enableOneTimeProducts().build(); 61 | billingClient = BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases(pendingPurchasesParams).build(); 62 | billingClient.startConnection(new BillingClientStateListener() { 63 | @Override 64 | public void onBillingSetupFinished(BillingResult billingResult) { 65 | if (billingResult.getResponseCode() == BillingResponseCode.OK) { 66 | Log.v(TAG, "Setup finished"); 67 | // NOTE: we will not query purchases here. This is done 68 | // when the extension listener is set 69 | } 70 | else { 71 | Log.wtf(TAG, "Setup error: " + billingResult.getDebugMessage()); 72 | } 73 | } 74 | 75 | @Override 76 | public void onBillingServiceDisconnected() { 77 | Log.v(TAG, "Service disconnected"); 78 | } 79 | }); 80 | } 81 | 82 | public void stop() { 83 | Log.d(TAG, "stop()"); 84 | if (billingClient.isReady()) { 85 | billingClient.endConnection(); 86 | } 87 | } 88 | 89 | private String toISO8601(final Date date) { 90 | String formatted = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").format(date); 91 | return formatted.substring(0, 22) + ":" + formatted.substring(22); 92 | } 93 | 94 | private String convertPurchase(Purchase purchase) { 95 | // original JSON: 96 | // { 97 | // "orderId":"GPA.3301-1670-7033-37542", 98 | // "packageName":"com.defold.extension.iap", 99 | // "productId":"com.defold.iap.goldbar.medium", 100 | // "purchaseTime":1595455967875, 101 | // "purchaseState":0, 102 | // "purchaseToken":"kacckamkehbbammphdcnhbme.AO-J1OxznnK6E8ILqaAgrPa-3sfaHny424R1e_ZJ2LkaJVsy-5aEOmHizw0vUp-017m8OUvw1rSvfAHbOog1fIvDGJmjaze3MEVFOh1ayJsNFfPDUGwMA_u_9rlV7OqX_nnIyDShH2KE5WrnMC0yQyw7sg5hfgeW6A", 103 | // "acknowledged":false 104 | // } 105 | Log.d(TAG, "convertPurchase() original json: " + purchase.getOriginalJson()); 106 | JSONObject p = new JSONObject(); 107 | try { 108 | JSONObject original = new JSONObject(purchase.getOriginalJson()); 109 | p.put("ident", original.get("productId")); 110 | p.put("state", purchaseStateToDefoldState(purchase.getPurchaseState())); 111 | p.put("trans_ident", purchase.getOrderId()); 112 | p.put("date", toISO8601(new Date(purchase.getPurchaseTime()))); 113 | p.put("receipt", purchase.getPurchaseToken()); 114 | p.put("signature", purchase.getSignature()); 115 | p.put("original_json", purchase.getOriginalJson()); 116 | } 117 | catch (JSONException e) { 118 | Log.wtf(TAG, "Failed to convert purchase", e); 119 | } 120 | return p.toString(); 121 | } 122 | 123 | private JSONArray convertSubscriptionOfferPricingPhases(SubscriptionOfferDetails details) { 124 | JSONArray a = new JSONArray(); 125 | try { 126 | List pricingPhases = details.getPricingPhases().getPricingPhaseList(); 127 | for (PricingPhase pricingPhase : pricingPhases) { 128 | JSONObject o = new JSONObject(); 129 | o.put("price_string", pricingPhase.getFormattedPrice()); 130 | o.put("price", pricingPhase.getPriceAmountMicros() * 0.000001); 131 | o.put("currency_code", pricingPhase.getPriceCurrencyCode()); 132 | o.put("billing_period", pricingPhase.getBillingPeriod()); 133 | o.put("billing_cycle_count", pricingPhase.getBillingCycleCount()); 134 | switch (pricingPhase.getRecurrenceMode()) { 135 | case RecurrenceMode.FINITE_RECURRING: 136 | o.put("recurrence_mode", "FINITE"); 137 | break; 138 | case RecurrenceMode.INFINITE_RECURRING: 139 | o.put("recurrence_mode", "INFINITE"); 140 | break; 141 | default: 142 | case RecurrenceMode.NON_RECURRING: 143 | o.put("recurrence_mode", "NONE"); 144 | break; 145 | } 146 | a.put(o); 147 | } 148 | } 149 | catch(JSONException e) { 150 | Log.wtf(TAG, "Failed to convert subscription offer pricing phases", e); 151 | } 152 | return a; 153 | } 154 | 155 | private JSONObject convertProductDetails(ProductDetails productDetails) { 156 | JSONObject p = new JSONObject(); 157 | try { 158 | p.put("ident", productDetails.getProductId()); 159 | p.put("title", productDetails.getTitle()); 160 | p.put("description", productDetails.getDescription()); 161 | if (productDetails.getProductType().equals(ProductType.INAPP)) { 162 | OneTimePurchaseOfferDetails offerDetails = productDetails.getOneTimePurchaseOfferDetails(); 163 | p.put("price_string", offerDetails.getFormattedPrice()); 164 | p.put("currency_code", offerDetails.getPriceCurrencyCode()); 165 | p.put("price", offerDetails.getPriceAmountMicros() * 0.000001); 166 | } 167 | else if (productDetails.getProductType().equals(ProductType.SUBS)) { 168 | List subscriptionOfferDetails = productDetails.getSubscriptionOfferDetails(); 169 | JSONArray a = new JSONArray(); 170 | for (SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) { 171 | JSONObject o = new JSONObject(); 172 | o.put("token", offerDetails.getOfferToken()); 173 | o.put("pricing", convertSubscriptionOfferPricingPhases(offerDetails)); 174 | a.put(o); 175 | } 176 | p.put("subscriptions", a); 177 | } 178 | else { 179 | Log.i(TAG, "convertProductDetails() unknown product type " + productDetails.getProductType()); 180 | } 181 | } 182 | catch(JSONException e) { 183 | Log.wtf(TAG, "Failed to convert product details", e); 184 | } 185 | return p; 186 | } 187 | 188 | private int purchaseStateToDefoldState(int purchaseState) { 189 | int defoldState; 190 | switch(purchaseState) { 191 | case PurchaseState.PENDING: 192 | defoldState = IapJNI.TRANS_STATE_PURCHASING; 193 | break; 194 | case PurchaseState.PURCHASED: 195 | defoldState = IapJNI.TRANS_STATE_PURCHASED; 196 | break; 197 | default: 198 | case PurchaseState.UNSPECIFIED_STATE: 199 | defoldState = IapJNI.TRANS_STATE_UNVERIFIED; 200 | break; 201 | } 202 | return defoldState; 203 | } 204 | 205 | private int billingResponseCodeToDefoldResponse(int responseCode) { 206 | int defoldResponse; 207 | switch(responseCode) { 208 | case BillingResponseCode.BILLING_UNAVAILABLE: 209 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE; 210 | break; 211 | case BillingResponseCode.DEVELOPER_ERROR: 212 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR; 213 | break; 214 | case BillingResponseCode.ITEM_ALREADY_OWNED: 215 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED; 216 | break; 217 | case BillingResponseCode.ITEM_NOT_OWNED: 218 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED; 219 | break; 220 | case BillingResponseCode.ITEM_UNAVAILABLE: 221 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE; 222 | break; 223 | case BillingResponseCode.OK: 224 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_OK; 225 | break; 226 | case BillingResponseCode.SERVICE_UNAVAILABLE: 227 | case BillingResponseCode.SERVICE_DISCONNECTED: 228 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE; 229 | break; 230 | case BillingResponseCode.USER_CANCELED: 231 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_USER_CANCELED; 232 | break; 233 | case BillingResponseCode.NETWORK_ERROR: // new in Play Billing Library 6.0.0 234 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_NETWORK_ERROR; 235 | break; 236 | case BillingResponseCode.FEATURE_NOT_SUPPORTED: 237 | case BillingResponseCode.ERROR: 238 | default: 239 | defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_ERROR; 240 | break; 241 | } 242 | Log.d(TAG, "billingResponseCodeToDefoldResponse: " + responseCode + " defoldResponse: " + defoldResponse); 243 | return defoldResponse; 244 | } 245 | 246 | private int billingResultToDefoldResponse(BillingResult result) { 247 | return billingResponseCodeToDefoldResponse(result.getResponseCode()); 248 | } 249 | 250 | private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, int billingResultCode, String purchaseData) { 251 | if (purchaseListener == null) { 252 | Log.w(TAG, "Received billing result but no listener has been set"); 253 | return; 254 | } 255 | purchaseListener.onPurchaseResult(billingResultCode, purchaseData); 256 | } 257 | private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, BillingResult billingResult, Purchase purchase) { 258 | invokeOnPurchaseResultListener(purchaseListener, billingResultToDefoldResponse(billingResult), convertPurchase(purchase)); 259 | } 260 | private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, BillingResult billingResult) { 261 | invokeOnPurchaseResultListener(purchaseListener, billingResultToDefoldResponse(billingResult), ""); 262 | } 263 | 264 | /** 265 | * This method is called either explicitly from Lua or from extension code 266 | * when "set_listener()" is called from Lua. 267 | * The method will query purchases and try to handle them one by one (either 268 | * trying to consume/finish them or simply notify the provided listener). 269 | */ 270 | public void processPendingConsumables(final IPurchaseListener purchaseListener) { 271 | Log.d(TAG, "processPendingConsumables()"); 272 | 273 | 274 | PurchasesResponseListener purchasesListener = new PurchasesResponseListener() { 275 | private List allPurchases = new ArrayList(); 276 | private int queries = 2; 277 | 278 | @Override 279 | public void onQueryPurchasesResponse(BillingResult billingResult, List purchases) { 280 | if (billingResult.getResponseCode() != BillingResponseCode.OK) { 281 | Log.e(TAG, "Unable to query pending purchases: " + billingResult.getDebugMessage()); 282 | } 283 | if (purchases != null) { 284 | allPurchases.addAll(purchases); 285 | } 286 | // we're finished when we have queried for both in-app and subs 287 | queries--; 288 | if (queries == 0) { 289 | for (Purchase purchase : allPurchases) { 290 | handlePurchase(purchase, purchaseListener); 291 | } 292 | } 293 | } 294 | }; 295 | 296 | final QueryPurchasesParams inappParams = QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build(); 297 | final QueryPurchasesParams subsParams = QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build(); 298 | billingClient.queryPurchasesAsync(inappParams, purchasesListener); 299 | billingClient.queryPurchasesAsync(subsParams, purchasesListener); 300 | } 301 | 302 | /** 303 | * Consume a purchase. This will acknowledge the purchase and make it 304 | * available to buy again. 305 | */ 306 | private void consumePurchase(final String purchaseToken, final ConsumeResponseListener consumeListener) { 307 | Log.d(TAG, "consumePurchase() " + purchaseToken); 308 | final ConsumeParams consumeParams = ConsumeParams.newBuilder() 309 | .setPurchaseToken(purchaseToken) 310 | .build(); 311 | 312 | billingClient.consumeAsync(consumeParams, consumeListener); 313 | } 314 | 315 | /** 316 | * Called from Lua. This method will try to consume a purchase. 317 | */ 318 | public void finishTransaction(final String purchaseToken, final IPurchaseListener purchaseListener) { 319 | Log.d(TAG, "finishTransaction() " + purchaseToken); 320 | consumePurchase(purchaseToken, new ConsumeResponseListener() { 321 | @Override 322 | public void onConsumeResponse(BillingResult billingResult, String purchaseToken) { 323 | Log.d(TAG, "finishTransaction() response code " + billingResult.getResponseCode() + " purchaseToken: " + purchaseToken); 324 | // note: we only call the purchase listener if an error happens 325 | if (billingResult.getResponseCode() != BillingResponseCode.OK) { 326 | Log.e(TAG, "Unable to consume purchase: " + billingResult.getDebugMessage()); 327 | invokeOnPurchaseResultListener(purchaseListener, billingResult); 328 | } 329 | } 330 | }); 331 | } 332 | 333 | /** 334 | * Called from Lua. This method will try to acknowledge a purchase (but not finish/consume it). 335 | */ 336 | public void acknowledgeTransaction(final String purchaseToken, final IPurchaseListener purchaseListener) { 337 | Log.d(TAG, "acknowledgeTransaction() " + purchaseToken); 338 | 339 | AcknowledgePurchaseParams acknowledgeParams = AcknowledgePurchaseParams.newBuilder() 340 | .setPurchaseToken(purchaseToken) 341 | .build(); 342 | 343 | billingClient.acknowledgePurchase(acknowledgeParams, new AcknowledgePurchaseResponseListener() { 344 | @Override 345 | public void onAcknowledgePurchaseResponse(BillingResult billingResult) { 346 | Log.d(TAG, "acknowledgeTransaction() response code " + billingResult.getResponseCode()); 347 | // note: we only call the purchase listener if an error happens 348 | if (billingResult.getResponseCode() != BillingResponseCode.OK) { 349 | Log.e(TAG, "Unable to acknowledge purchase: " + billingResult.getDebugMessage()); 350 | invokeOnPurchaseResultListener(purchaseListener, billingResult); 351 | } 352 | } 353 | }); 354 | } 355 | 356 | /** 357 | * Handle a purchase. If the extension is configured to automatically 358 | * finish transactions the purchase will be immediately consumed. Otherwise 359 | * the product will be returned via the listener without being consumed. 360 | * NOTE: Billing 3.0+ requires purchases to be acknowledged within 3 days of 361 | * purchase unless they are consumed. 362 | */ 363 | private void handlePurchase(final Purchase purchase, final IPurchaseListener purchaseListener) { 364 | if (this.autoFinishTransactions) { 365 | consumePurchase(purchase.getPurchaseToken(), new ConsumeResponseListener() { 366 | @Override 367 | public void onConsumeResponse(BillingResult billingResult, String purchaseToken) { 368 | Log.d(TAG, "handlePurchase() response code " + billingResult.getResponseCode() + " purchaseToken: " + purchaseToken); 369 | invokeOnPurchaseResultListener(purchaseListener, billingResult, purchase); 370 | } 371 | }); 372 | } 373 | else { 374 | invokeOnPurchaseResultListener(purchaseListener, billingResponseCodeToDefoldResponse(BillingResponseCode.OK), convertPurchase(purchase)); 375 | } 376 | } 377 | 378 | /** 379 | * BillingClient listener set in the constructor. 380 | */ 381 | @Override 382 | public void onPurchasesUpdated(BillingResult billingResult, List purchases) { 383 | if (billingResult.getResponseCode() == BillingResponseCode.OK) { 384 | if (purchases != null && !purchases.isEmpty()) { 385 | for (Purchase purchase : purchases) { 386 | if (purchase != null) { 387 | handlePurchase(purchase, this.purchaseListener); 388 | } 389 | } 390 | } 391 | } 392 | else { 393 | invokeOnPurchaseResultListener(this.purchaseListener, billingResult); 394 | } 395 | } 396 | 397 | /** 398 | * Buy a product. This method stores the listener and uses it in the 399 | * onPurchasesUpdated() callback. 400 | */ 401 | private void buyProduct(ProductDetails pd, final String token, final IPurchaseListener purchaseListener) { 402 | this.purchaseListener = purchaseListener; 403 | 404 | List productDetailsParams = new ArrayList(); 405 | if (pd.getProductType().equals(ProductType.SUBS)) { 406 | productDetailsParams.add(ProductDetailsParams.newBuilder().setProductDetails(pd).setOfferToken(token).build()); 407 | } 408 | else { 409 | productDetailsParams.add(ProductDetailsParams.newBuilder().setProductDetails(pd).build()); 410 | } 411 | 412 | final BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParams).build(); 413 | 414 | BillingResult billingResult = billingClient.launchBillingFlow(this.activity, billingFlowParams); 415 | if (billingResult.getResponseCode() != BillingResponseCode.OK) { 416 | Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage()); 417 | invokeOnPurchaseResultListener(purchaseListener, billingResult); 418 | } 419 | } 420 | 421 | /** 422 | * Called from Lua. 423 | */ 424 | public void buy(final String product, final String token, final IPurchaseListener purchaseListener) { 425 | Log.d(TAG, "buy()"); 426 | 427 | ProductDetails pd = this.products.get(product); 428 | if (pd != null) { 429 | buyProduct(pd, token, purchaseListener); 430 | } 431 | else { 432 | List productList = new ArrayList(); 433 | productList.add(product); 434 | queryProductDetailsAsync(productList, new ProductDetailsResponseListener() { 435 | @Override 436 | public void onProductDetailsResponse(BillingResult billingResult, List productDetailsList) { 437 | if (billingResult.getResponseCode() == BillingResponseCode.OK && (productDetailsList != null) && !productDetailsList.isEmpty()) { 438 | for (ProductDetails productDetails : productDetailsList) { 439 | if (productDetails != null) { 440 | buyProduct(productDetails, token, purchaseListener); 441 | break; 442 | } 443 | } 444 | } 445 | else { 446 | Log.e(TAG, "Unable to get product details before buying: " + billingResult.getDebugMessage()); 447 | invokeOnPurchaseResultListener(purchaseListener, billingResult); 448 | } 449 | } 450 | }); 451 | } 452 | } 453 | 454 | /** 455 | * Get details for a list of products. The products can be a mix of 456 | * in-app products and subscriptions. 457 | */ 458 | private void queryProductDetailsAsync(final List productList, final ProductDetailsResponseListener listener) { 459 | ProductDetailsResponseListener detailsListener = new ProductDetailsResponseListener() { 460 | private List allProductDetails = new ArrayList(); 461 | private int queries = 2; 462 | 463 | @Override 464 | public void onProductDetailsResponse(BillingResult billingResult, List productDetails) { 465 | if (productDetails != null && !productDetails.isEmpty()) { 466 | // cache products (cache will be used to speed up buying) 467 | for (ProductDetails pd : productDetails) { 468 | if (pd != null) { 469 | IapGooglePlay.this.products.put(pd.getProductId(), pd); 470 | } 471 | } 472 | // add to list of all product details 473 | allProductDetails.addAll(productDetails); 474 | } 475 | // we're finished when we have queried for both in-app and subs 476 | queries--; 477 | if (queries == 0) { 478 | listener.onProductDetailsResponse(billingResult, allProductDetails); 479 | } 480 | } 481 | }; 482 | 483 | // we don't know if a product is a subscription or inapp product type 484 | // instread we create two product lists from the same set of products and use INAPP for one and SUBS for the other 485 | List inappProductList = new ArrayList(); 486 | List subsProductList = new ArrayList(); 487 | for (String productId : productList) { 488 | inappProductList.add(Product.newBuilder().setProductId(productId).setProductType(ProductType.INAPP).build()); 489 | subsProductList.add(Product.newBuilder().setProductId(productId).setProductType(ProductType.SUBS).build()); 490 | } 491 | 492 | // do one query per product type 493 | final QueryProductDetailsParams inappParams = QueryProductDetailsParams.newBuilder().setProductList(inappProductList).build(); 494 | final QueryProductDetailsParams subsParams = QueryProductDetailsParams.newBuilder().setProductList(subsProductList).build(); 495 | billingClient.queryProductDetailsAsync(inappParams, detailsListener); 496 | billingClient.queryProductDetailsAsync(subsParams, detailsListener); 497 | } 498 | 499 | /** 500 | * Called from Lua. 501 | */ 502 | public void listItems(final String products, final IListProductsListener productsListener, final long commandPtr) { 503 | Log.d(TAG, "listItems()"); 504 | 505 | // create list of product ids from comma separated string 506 | List productList = new ArrayList(); 507 | for (String p : products.split(",")) { 508 | if (p.trim().length() > 0) { 509 | productList.add(p); 510 | } 511 | } 512 | 513 | queryProductDetailsAsync(productList, new ProductDetailsResponseListener() { 514 | @Override 515 | public void onProductDetailsResponse(BillingResult billingResult, List productDetails) { 516 | JSONArray a = new JSONArray(); 517 | if ((billingResult.getResponseCode() == BillingResponseCode.OK) && (productDetails != null) && !productDetails.isEmpty()) { 518 | for (ProductDetails pd : productDetails) { 519 | if (pd != null) { 520 | a.put(convertProductDetails(pd)); 521 | } 522 | } 523 | } 524 | else { 525 | Log.e(TAG, "Unable to list products: " + billingResult.getDebugMessage()); 526 | } 527 | productsListener.onProductsResult(billingResultToDefoldResponse(billingResult), a.toString(), commandPtr); 528 | } 529 | }); 530 | } 531 | 532 | /** 533 | * Called from Lua. 534 | */ 535 | public void restore(final IPurchaseListener listener) { 536 | Log.d(TAG, "restore()"); 537 | processPendingConsumables(listener); 538 | } 539 | } 540 | -------------------------------------------------------------------------------- /extension-iap/src/java/com/defold/iap/IapJNI.java: -------------------------------------------------------------------------------- 1 | package com.defold.iap; 2 | 3 | public class IapJNI implements IListProductsListener, IPurchaseListener { 4 | 5 | // NOTE: Also defined in iap.h 6 | public static final int TRANS_STATE_PURCHASING = 0; 7 | public static final int TRANS_STATE_PURCHASED = 1; 8 | public static final int TRANS_STATE_FAILED = 2; 9 | public static final int TRANS_STATE_RESTORED = 3; 10 | public static final int TRANS_STATE_UNVERIFIED = 4; 11 | 12 | public static final int BILLING_RESPONSE_RESULT_OK = 0; 13 | public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; 14 | public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2; 15 | public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; 16 | public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; 17 | public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; 18 | public static final int BILLING_RESPONSE_RESULT_ERROR = 6; 19 | public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; 20 | public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; 21 | public static final int BILLING_RESPONSE_RESULT_NETWORK_ERROR = 9; 22 | 23 | public IapJNI() { 24 | } 25 | 26 | @Override 27 | public native void onProductsResult(int responseCode, String productList, long cmdHandle); 28 | 29 | @Override 30 | public native void onPurchaseResult(int responseCode, String purchaseData); 31 | 32 | } 33 | -------------------------------------------------------------------------------- /game.project: -------------------------------------------------------------------------------- 1 | [bootstrap] 2 | main_collection = /main/main.collectionc 3 | 4 | [script] 5 | shared_state = 1 6 | 7 | [display] 8 | width = 640 9 | height = 1136 10 | 11 | [android] 12 | input_method = HiddenInputField 13 | package = com.defold.extension.iap 14 | version_code = 9 15 | minimum_sdk_version = 21 16 | 17 | [project] 18 | title = extension-iap 19 | dependencies#0 = https://github.com/andsve/dirtylarry/archive/master.zip 20 | 21 | [library] 22 | include_dirs = extension-iap 23 | 24 | [ios] 25 | bundle_identifier = com.defold.extension.iap 26 | 27 | [iap] 28 | auto_finish_transactions = 0 29 | 30 | [osx] 31 | bundle_identifier = com.defold.extension.iap 32 | 33 | -------------------------------------------------------------------------------- /input/game.input_binding: -------------------------------------------------------------------------------- 1 | mouse_trigger { 2 | input: MOUSE_BUTTON_1 3 | action: "touch" 4 | } 5 | -------------------------------------------------------------------------------- /main/main.collection: -------------------------------------------------------------------------------- 1 | name: "main" 2 | scale_along_z: 0 3 | embedded_instances { 4 | id: "go" 5 | data: "components {\n" 6 | " id: \"main\"\n" 7 | " component: \"/main/main.gui\"\n" 8 | " position {\n" 9 | " x: 0.0\n" 10 | " y: 0.0\n" 11 | " z: 0.0\n" 12 | " }\n" 13 | " rotation {\n" 14 | " x: 0.0\n" 15 | " y: 0.0\n" 16 | " z: 0.0\n" 17 | " w: 1.0\n" 18 | " }\n" 19 | "}\n" 20 | "" 21 | position { 22 | x: 0.0 23 | y: 0.0 24 | z: 0.0 25 | } 26 | rotation { 27 | x: 0.0 28 | y: 0.0 29 | z: 0.0 30 | w: 1.0 31 | } 32 | scale3 { 33 | x: 1.0 34 | y: 1.0 35 | z: 1.0 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /main/main.gui: -------------------------------------------------------------------------------- 1 | script: "/main/main.gui_script" 2 | fonts { 3 | name: "default" 4 | font: "/builtins/fonts/default.font" 5 | } 6 | background_color { 7 | x: 0.0 8 | y: 0.0 9 | z: 0.0 10 | w: 0.0 11 | } 12 | nodes { 13 | position { 14 | x: 171.0 15 | y: 569.0 16 | z: 0.0 17 | w: 1.0 18 | } 19 | rotation { 20 | x: 0.0 21 | y: 0.0 22 | z: 0.0 23 | w: 1.0 24 | } 25 | scale { 26 | x: 1.0 27 | y: 1.0 28 | z: 1.0 29 | w: 1.0 30 | } 31 | size { 32 | x: 200.0 33 | y: 100.0 34 | z: 0.0 35 | w: 1.0 36 | } 37 | color { 38 | x: 1.0 39 | y: 1.0 40 | z: 1.0 41 | w: 1.0 42 | } 43 | type: TYPE_TEMPLATE 44 | id: "goldbars_small" 45 | layer: "" 46 | inherit_alpha: true 47 | alpha: 1.0 48 | template: "/dirtylarry/button.gui" 49 | template_node_child: false 50 | custom_type: 0 51 | enabled: true 52 | } 53 | nodes { 54 | position { 55 | x: 0.0 56 | y: 0.0 57 | z: 0.0 58 | w: 1.0 59 | } 60 | rotation { 61 | x: 0.0 62 | y: 0.0 63 | z: 0.0 64 | w: 1.0 65 | } 66 | scale { 67 | x: 1.0 68 | y: 1.0 69 | z: 1.0 70 | w: 1.0 71 | } 72 | size { 73 | x: 300.0 74 | y: 88.0 75 | z: 0.0 76 | w: 1.0 77 | } 78 | color { 79 | x: 1.0 80 | y: 1.0 81 | z: 1.0 82 | w: 1.0 83 | } 84 | type: TYPE_BOX 85 | blend_mode: BLEND_MODE_ALPHA 86 | texture: "button/button_normal" 87 | id: "goldbars_small/larrybutton" 88 | xanchor: XANCHOR_NONE 89 | yanchor: YANCHOR_NONE 90 | pivot: PIVOT_CENTER 91 | adjust_mode: ADJUST_MODE_FIT 92 | parent: "goldbars_small" 93 | layer: "" 94 | inherit_alpha: true 95 | slice9 { 96 | x: 32.0 97 | y: 32.0 98 | z: 32.0 99 | w: 32.0 100 | } 101 | clipping_mode: CLIPPING_MODE_NONE 102 | clipping_visible: true 103 | clipping_inverted: false 104 | alpha: 1.0 105 | template_node_child: true 106 | size_mode: SIZE_MODE_MANUAL 107 | custom_type: 0 108 | enabled: true 109 | visible: true 110 | material: "" 111 | } 112 | nodes { 113 | position { 114 | x: 0.0 115 | y: 0.0 116 | z: 0.0 117 | w: 1.0 118 | } 119 | rotation { 120 | x: 0.0 121 | y: 0.0 122 | z: 0.0 123 | w: 1.0 124 | } 125 | scale { 126 | x: 1.0 127 | y: 1.0 128 | z: 1.0 129 | w: 1.0 130 | } 131 | size { 132 | x: 200.0 133 | y: 100.0 134 | z: 0.0 135 | w: 1.0 136 | } 137 | color { 138 | x: 1.0 139 | y: 1.0 140 | z: 1.0 141 | w: 1.0 142 | } 143 | type: TYPE_TEXT 144 | blend_mode: BLEND_MODE_ALPHA 145 | text: "Goldbars - S" 146 | font: "larryfont" 147 | id: "goldbars_small/larrylabel" 148 | xanchor: XANCHOR_NONE 149 | yanchor: YANCHOR_NONE 150 | pivot: PIVOT_CENTER 151 | outline { 152 | x: 0.0 153 | y: 0.0 154 | z: 0.0 155 | w: 1.0 156 | } 157 | shadow { 158 | x: 1.0 159 | y: 1.0 160 | z: 1.0 161 | w: 1.0 162 | } 163 | adjust_mode: ADJUST_MODE_FIT 164 | line_break: false 165 | parent: "goldbars_small/larrybutton" 166 | layer: "" 167 | inherit_alpha: true 168 | alpha: 1.0 169 | outline_alpha: 1.0 170 | shadow_alpha: 1.0 171 | overridden_fields: 8 172 | template_node_child: true 173 | text_leading: 1.0 174 | text_tracking: 0.0 175 | custom_type: 0 176 | enabled: true 177 | visible: true 178 | material: "" 179 | } 180 | nodes { 181 | position { 182 | x: 530.0 183 | y: 56.0 184 | z: 0.0 185 | w: 1.0 186 | } 187 | rotation { 188 | x: 0.0 189 | y: 0.0 190 | z: 0.0 191 | w: 1.0 192 | } 193 | scale { 194 | x: 1.0 195 | y: 1.0 196 | z: 1.0 197 | w: 1.0 198 | } 199 | size { 200 | x: 200.0 201 | y: 100.0 202 | z: 0.0 203 | w: 1.0 204 | } 205 | color { 206 | x: 1.0 207 | y: 1.0 208 | z: 1.0 209 | w: 1.0 210 | } 211 | type: TYPE_TEMPLATE 212 | id: "restore" 213 | layer: "" 214 | inherit_alpha: true 215 | alpha: 1.0 216 | template: "/dirtylarry/button.gui" 217 | template_node_child: false 218 | custom_type: 0 219 | enabled: true 220 | } 221 | nodes { 222 | position { 223 | x: 0.0 224 | y: 0.0 225 | z: 0.0 226 | w: 1.0 227 | } 228 | rotation { 229 | x: 0.0 230 | y: 0.0 231 | z: 0.0 232 | w: 1.0 233 | } 234 | scale { 235 | x: 1.0 236 | y: 1.0 237 | z: 1.0 238 | w: 1.0 239 | } 240 | size { 241 | x: 200.0 242 | y: 88.0 243 | z: 0.0 244 | w: 1.0 245 | } 246 | color { 247 | x: 1.0 248 | y: 1.0 249 | z: 1.0 250 | w: 1.0 251 | } 252 | type: TYPE_BOX 253 | blend_mode: BLEND_MODE_ALPHA 254 | texture: "button/button_normal" 255 | id: "restore/larrybutton" 256 | xanchor: XANCHOR_NONE 257 | yanchor: YANCHOR_NONE 258 | pivot: PIVOT_CENTER 259 | adjust_mode: ADJUST_MODE_FIT 260 | parent: "restore" 261 | layer: "" 262 | inherit_alpha: true 263 | slice9 { 264 | x: 32.0 265 | y: 32.0 266 | z: 32.0 267 | w: 32.0 268 | } 269 | clipping_mode: CLIPPING_MODE_NONE 270 | clipping_visible: true 271 | clipping_inverted: false 272 | alpha: 1.0 273 | overridden_fields: 4 274 | template_node_child: true 275 | size_mode: SIZE_MODE_MANUAL 276 | custom_type: 0 277 | enabled: true 278 | visible: true 279 | material: "" 280 | } 281 | nodes { 282 | position { 283 | x: 0.0 284 | y: 0.0 285 | z: 0.0 286 | w: 1.0 287 | } 288 | rotation { 289 | x: 0.0 290 | y: 0.0 291 | z: 0.0 292 | w: 1.0 293 | } 294 | scale { 295 | x: 1.0 296 | y: 1.0 297 | z: 1.0 298 | w: 1.0 299 | } 300 | size { 301 | x: 200.0 302 | y: 100.0 303 | z: 0.0 304 | w: 1.0 305 | } 306 | color { 307 | x: 1.0 308 | y: 1.0 309 | z: 1.0 310 | w: 1.0 311 | } 312 | type: TYPE_TEXT 313 | blend_mode: BLEND_MODE_ALPHA 314 | text: "Restore" 315 | font: "larryfont" 316 | id: "restore/larrylabel" 317 | xanchor: XANCHOR_NONE 318 | yanchor: YANCHOR_NONE 319 | pivot: PIVOT_CENTER 320 | outline { 321 | x: 0.0 322 | y: 0.0 323 | z: 0.0 324 | w: 1.0 325 | } 326 | shadow { 327 | x: 1.0 328 | y: 1.0 329 | z: 1.0 330 | w: 1.0 331 | } 332 | adjust_mode: ADJUST_MODE_FIT 333 | line_break: false 334 | parent: "restore/larrybutton" 335 | layer: "" 336 | inherit_alpha: true 337 | alpha: 1.0 338 | outline_alpha: 1.0 339 | shadow_alpha: 1.0 340 | overridden_fields: 8 341 | template_node_child: true 342 | text_leading: 1.0 343 | text_tracking: 0.0 344 | custom_type: 0 345 | enabled: true 346 | visible: true 347 | material: "" 348 | } 349 | nodes { 350 | position { 351 | x: 20.0 352 | y: 1124.0 353 | z: 0.0 354 | w: 1.0 355 | } 356 | rotation { 357 | x: 0.0 358 | y: 0.0 359 | z: 0.0 360 | w: 1.0 361 | } 362 | scale { 363 | x: 1.0 364 | y: 1.0 365 | z: 1.0 366 | w: 1.0 367 | } 368 | size { 369 | x: 600.0 370 | y: 450.0 371 | z: 0.0 372 | w: 1.0 373 | } 374 | color { 375 | x: 1.0 376 | y: 1.0 377 | z: 1.0 378 | w: 1.0 379 | } 380 | type: TYPE_TEXT 381 | blend_mode: BLEND_MODE_ALPHA 382 | text: "" 383 | font: "default" 384 | id: "log" 385 | xanchor: XANCHOR_NONE 386 | yanchor: YANCHOR_NONE 387 | pivot: PIVOT_NW 388 | outline { 389 | x: 1.0 390 | y: 1.0 391 | z: 1.0 392 | w: 1.0 393 | } 394 | shadow { 395 | x: 1.0 396 | y: 1.0 397 | z: 1.0 398 | w: 1.0 399 | } 400 | adjust_mode: ADJUST_MODE_FIT 401 | line_break: true 402 | layer: "" 403 | inherit_alpha: true 404 | alpha: 1.0 405 | outline_alpha: 1.0 406 | shadow_alpha: 1.0 407 | template_node_child: false 408 | text_leading: 1.0 409 | text_tracking: 0.0 410 | custom_type: 0 411 | enabled: true 412 | visible: true 413 | material: "" 414 | } 415 | nodes { 416 | position { 417 | x: 171.0 418 | y: 465.0 419 | z: 0.0 420 | w: 1.0 421 | } 422 | rotation { 423 | x: 0.0 424 | y: 0.0 425 | z: 0.0 426 | w: 1.0 427 | } 428 | scale { 429 | x: 1.0 430 | y: 1.0 431 | z: 1.0 432 | w: 1.0 433 | } 434 | size { 435 | x: 200.0 436 | y: 100.0 437 | z: 0.0 438 | w: 1.0 439 | } 440 | color { 441 | x: 1.0 442 | y: 1.0 443 | z: 1.0 444 | w: 1.0 445 | } 446 | type: TYPE_TEMPLATE 447 | id: "goldbars_medium" 448 | layer: "" 449 | inherit_alpha: true 450 | alpha: 1.0 451 | template: "/dirtylarry/button.gui" 452 | template_node_child: false 453 | custom_type: 0 454 | enabled: true 455 | } 456 | nodes { 457 | position { 458 | x: 0.0 459 | y: 0.0 460 | z: 0.0 461 | w: 1.0 462 | } 463 | rotation { 464 | x: 0.0 465 | y: 0.0 466 | z: 0.0 467 | w: 1.0 468 | } 469 | scale { 470 | x: 1.0 471 | y: 1.0 472 | z: 1.0 473 | w: 1.0 474 | } 475 | size { 476 | x: 300.0 477 | y: 88.0 478 | z: 0.0 479 | w: 1.0 480 | } 481 | color { 482 | x: 1.0 483 | y: 1.0 484 | z: 1.0 485 | w: 1.0 486 | } 487 | type: TYPE_BOX 488 | blend_mode: BLEND_MODE_ALPHA 489 | texture: "button/button_normal" 490 | id: "goldbars_medium/larrybutton" 491 | xanchor: XANCHOR_NONE 492 | yanchor: YANCHOR_NONE 493 | pivot: PIVOT_CENTER 494 | adjust_mode: ADJUST_MODE_FIT 495 | parent: "goldbars_medium" 496 | layer: "" 497 | inherit_alpha: true 498 | slice9 { 499 | x: 32.0 500 | y: 32.0 501 | z: 32.0 502 | w: 32.0 503 | } 504 | clipping_mode: CLIPPING_MODE_NONE 505 | clipping_visible: true 506 | clipping_inverted: false 507 | alpha: 1.0 508 | template_node_child: true 509 | size_mode: SIZE_MODE_MANUAL 510 | custom_type: 0 511 | enabled: true 512 | visible: true 513 | material: "" 514 | } 515 | nodes { 516 | position { 517 | x: 0.0 518 | y: 0.0 519 | z: 0.0 520 | w: 1.0 521 | } 522 | rotation { 523 | x: 0.0 524 | y: 0.0 525 | z: 0.0 526 | w: 1.0 527 | } 528 | scale { 529 | x: 1.0 530 | y: 1.0 531 | z: 1.0 532 | w: 1.0 533 | } 534 | size { 535 | x: 200.0 536 | y: 100.0 537 | z: 0.0 538 | w: 1.0 539 | } 540 | color { 541 | x: 1.0 542 | y: 1.0 543 | z: 1.0 544 | w: 1.0 545 | } 546 | type: TYPE_TEXT 547 | blend_mode: BLEND_MODE_ALPHA 548 | text: "Goldbars - M" 549 | font: "larryfont" 550 | id: "goldbars_medium/larrylabel" 551 | xanchor: XANCHOR_NONE 552 | yanchor: YANCHOR_NONE 553 | pivot: PIVOT_CENTER 554 | outline { 555 | x: 0.0 556 | y: 0.0 557 | z: 0.0 558 | w: 1.0 559 | } 560 | shadow { 561 | x: 1.0 562 | y: 1.0 563 | z: 1.0 564 | w: 1.0 565 | } 566 | adjust_mode: ADJUST_MODE_FIT 567 | line_break: false 568 | parent: "goldbars_medium/larrybutton" 569 | layer: "" 570 | inherit_alpha: true 571 | alpha: 1.0 572 | outline_alpha: 1.0 573 | shadow_alpha: 1.0 574 | overridden_fields: 8 575 | template_node_child: true 576 | text_leading: 1.0 577 | text_tracking: 0.0 578 | custom_type: 0 579 | enabled: true 580 | visible: true 581 | material: "" 582 | } 583 | nodes { 584 | position { 585 | x: 171.0 586 | y: 357.0 587 | z: 0.0 588 | w: 1.0 589 | } 590 | rotation { 591 | x: 0.0 592 | y: 0.0 593 | z: 0.0 594 | w: 1.0 595 | } 596 | scale { 597 | x: 1.0 598 | y: 1.0 599 | z: 1.0 600 | w: 1.0 601 | } 602 | size { 603 | x: 200.0 604 | y: 100.0 605 | z: 0.0 606 | w: 1.0 607 | } 608 | color { 609 | x: 1.0 610 | y: 1.0 611 | z: 1.0 612 | w: 1.0 613 | } 614 | type: TYPE_TEMPLATE 615 | id: "goldbars_large" 616 | layer: "" 617 | inherit_alpha: true 618 | alpha: 1.0 619 | template: "/dirtylarry/button.gui" 620 | template_node_child: false 621 | custom_type: 0 622 | enabled: true 623 | } 624 | nodes { 625 | position { 626 | x: 0.0 627 | y: 0.0 628 | z: 0.0 629 | w: 1.0 630 | } 631 | rotation { 632 | x: 0.0 633 | y: 0.0 634 | z: 0.0 635 | w: 1.0 636 | } 637 | scale { 638 | x: 1.0 639 | y: 1.0 640 | z: 1.0 641 | w: 1.0 642 | } 643 | size { 644 | x: 300.0 645 | y: 88.0 646 | z: 0.0 647 | w: 1.0 648 | } 649 | color { 650 | x: 1.0 651 | y: 1.0 652 | z: 1.0 653 | w: 1.0 654 | } 655 | type: TYPE_BOX 656 | blend_mode: BLEND_MODE_ALPHA 657 | texture: "button/button_normal" 658 | id: "goldbars_large/larrybutton" 659 | xanchor: XANCHOR_NONE 660 | yanchor: YANCHOR_NONE 661 | pivot: PIVOT_CENTER 662 | adjust_mode: ADJUST_MODE_FIT 663 | parent: "goldbars_large" 664 | layer: "" 665 | inherit_alpha: true 666 | slice9 { 667 | x: 32.0 668 | y: 32.0 669 | z: 32.0 670 | w: 32.0 671 | } 672 | clipping_mode: CLIPPING_MODE_NONE 673 | clipping_visible: true 674 | clipping_inverted: false 675 | alpha: 1.0 676 | template_node_child: true 677 | size_mode: SIZE_MODE_MANUAL 678 | custom_type: 0 679 | enabled: true 680 | visible: true 681 | material: "" 682 | } 683 | nodes { 684 | position { 685 | x: 0.0 686 | y: 0.0 687 | z: 0.0 688 | w: 1.0 689 | } 690 | rotation { 691 | x: 0.0 692 | y: 0.0 693 | z: 0.0 694 | w: 1.0 695 | } 696 | scale { 697 | x: 1.0 698 | y: 1.0 699 | z: 1.0 700 | w: 1.0 701 | } 702 | size { 703 | x: 200.0 704 | y: 100.0 705 | z: 0.0 706 | w: 1.0 707 | } 708 | color { 709 | x: 1.0 710 | y: 1.0 711 | z: 1.0 712 | w: 1.0 713 | } 714 | type: TYPE_TEXT 715 | blend_mode: BLEND_MODE_ALPHA 716 | text: "Goldbars - L" 717 | font: "larryfont" 718 | id: "goldbars_large/larrylabel" 719 | xanchor: XANCHOR_NONE 720 | yanchor: YANCHOR_NONE 721 | pivot: PIVOT_CENTER 722 | outline { 723 | x: 0.0 724 | y: 0.0 725 | z: 0.0 726 | w: 1.0 727 | } 728 | shadow { 729 | x: 1.0 730 | y: 1.0 731 | z: 1.0 732 | w: 1.0 733 | } 734 | adjust_mode: ADJUST_MODE_FIT 735 | line_break: false 736 | parent: "goldbars_large/larrybutton" 737 | layer: "" 738 | inherit_alpha: true 739 | alpha: 1.0 740 | outline_alpha: 1.0 741 | shadow_alpha: 1.0 742 | overridden_fields: 8 743 | template_node_child: true 744 | text_leading: 1.0 745 | text_tracking: 0.0 746 | custom_type: 0 747 | enabled: true 748 | visible: true 749 | material: "" 750 | } 751 | nodes { 752 | position { 753 | x: 109.0 754 | y: 56.0 755 | z: 0.0 756 | w: 1.0 757 | } 758 | rotation { 759 | x: 0.0 760 | y: 0.0 761 | z: 0.0 762 | w: 1.0 763 | } 764 | scale { 765 | x: 1.0 766 | y: 1.0 767 | z: 1.0 768 | w: 1.0 769 | } 770 | size { 771 | x: 200.0 772 | y: 100.0 773 | z: 0.0 774 | w: 1.0 775 | } 776 | color { 777 | x: 1.0 778 | y: 1.0 779 | z: 1.0 780 | w: 1.0 781 | } 782 | type: TYPE_TEMPLATE 783 | id: "list" 784 | layer: "" 785 | inherit_alpha: true 786 | alpha: 1.0 787 | template: "/dirtylarry/button.gui" 788 | template_node_child: false 789 | custom_type: 0 790 | enabled: true 791 | } 792 | nodes { 793 | position { 794 | x: 0.0 795 | y: 0.0 796 | z: 0.0 797 | w: 1.0 798 | } 799 | rotation { 800 | x: 0.0 801 | y: 0.0 802 | z: 0.0 803 | w: 1.0 804 | } 805 | scale { 806 | x: 1.0 807 | y: 1.0 808 | z: 1.0 809 | w: 1.0 810 | } 811 | size { 812 | x: 200.0 813 | y: 88.0 814 | z: 0.0 815 | w: 1.0 816 | } 817 | color { 818 | x: 1.0 819 | y: 1.0 820 | z: 1.0 821 | w: 1.0 822 | } 823 | type: TYPE_BOX 824 | blend_mode: BLEND_MODE_ALPHA 825 | texture: "button/button_normal" 826 | id: "list/larrybutton" 827 | xanchor: XANCHOR_NONE 828 | yanchor: YANCHOR_NONE 829 | pivot: PIVOT_CENTER 830 | adjust_mode: ADJUST_MODE_FIT 831 | parent: "list" 832 | layer: "" 833 | inherit_alpha: true 834 | slice9 { 835 | x: 32.0 836 | y: 32.0 837 | z: 32.0 838 | w: 32.0 839 | } 840 | clipping_mode: CLIPPING_MODE_NONE 841 | clipping_visible: true 842 | clipping_inverted: false 843 | alpha: 1.0 844 | overridden_fields: 4 845 | template_node_child: true 846 | size_mode: SIZE_MODE_MANUAL 847 | custom_type: 0 848 | enabled: true 849 | visible: true 850 | material: "" 851 | } 852 | nodes { 853 | position { 854 | x: 0.0 855 | y: 0.0 856 | z: 0.0 857 | w: 1.0 858 | } 859 | rotation { 860 | x: 0.0 861 | y: 0.0 862 | z: 0.0 863 | w: 1.0 864 | } 865 | scale { 866 | x: 1.0 867 | y: 1.0 868 | z: 1.0 869 | w: 1.0 870 | } 871 | size { 872 | x: 200.0 873 | y: 100.0 874 | z: 0.0 875 | w: 1.0 876 | } 877 | color { 878 | x: 1.0 879 | y: 1.0 880 | z: 1.0 881 | w: 1.0 882 | } 883 | type: TYPE_TEXT 884 | blend_mode: BLEND_MODE_ALPHA 885 | text: "List" 886 | font: "larryfont" 887 | id: "list/larrylabel" 888 | xanchor: XANCHOR_NONE 889 | yanchor: YANCHOR_NONE 890 | pivot: PIVOT_CENTER 891 | outline { 892 | x: 0.0 893 | y: 0.0 894 | z: 0.0 895 | w: 1.0 896 | } 897 | shadow { 898 | x: 1.0 899 | y: 1.0 900 | z: 1.0 901 | w: 1.0 902 | } 903 | adjust_mode: ADJUST_MODE_FIT 904 | line_break: false 905 | parent: "list/larrybutton" 906 | layer: "" 907 | inherit_alpha: true 908 | alpha: 1.0 909 | outline_alpha: 1.0 910 | shadow_alpha: 1.0 911 | overridden_fields: 8 912 | template_node_child: true 913 | text_leading: 1.0 914 | text_tracking: 0.0 915 | custom_type: 0 916 | enabled: true 917 | visible: true 918 | material: "" 919 | } 920 | nodes { 921 | position { 922 | x: 171.0 923 | y: 251.0 924 | z: 0.0 925 | w: 1.0 926 | } 927 | rotation { 928 | x: 0.0 929 | y: 0.0 930 | z: 0.0 931 | w: 1.0 932 | } 933 | scale { 934 | x: 1.0 935 | y: 1.0 936 | z: 1.0 937 | w: 1.0 938 | } 939 | size { 940 | x: 200.0 941 | y: 100.0 942 | z: 0.0 943 | w: 1.0 944 | } 945 | color { 946 | x: 1.0 947 | y: 1.0 948 | z: 1.0 949 | w: 1.0 950 | } 951 | type: TYPE_TEMPLATE 952 | id: "subscription" 953 | layer: "" 954 | inherit_alpha: true 955 | alpha: 1.0 956 | template: "/dirtylarry/button.gui" 957 | template_node_child: false 958 | custom_type: 0 959 | enabled: true 960 | } 961 | nodes { 962 | position { 963 | x: 0.0 964 | y: 0.0 965 | z: 0.0 966 | w: 1.0 967 | } 968 | rotation { 969 | x: 0.0 970 | y: 0.0 971 | z: 0.0 972 | w: 1.0 973 | } 974 | scale { 975 | x: 1.0 976 | y: 1.0 977 | z: 1.0 978 | w: 1.0 979 | } 980 | size { 981 | x: 300.0 982 | y: 88.0 983 | z: 0.0 984 | w: 1.0 985 | } 986 | color { 987 | x: 1.0 988 | y: 1.0 989 | z: 1.0 990 | w: 1.0 991 | } 992 | type: TYPE_BOX 993 | blend_mode: BLEND_MODE_ALPHA 994 | texture: "button/button_normal" 995 | id: "subscription/larrybutton" 996 | xanchor: XANCHOR_NONE 997 | yanchor: YANCHOR_NONE 998 | pivot: PIVOT_CENTER 999 | adjust_mode: ADJUST_MODE_FIT 1000 | parent: "subscription" 1001 | layer: "" 1002 | inherit_alpha: true 1003 | slice9 { 1004 | x: 32.0 1005 | y: 32.0 1006 | z: 32.0 1007 | w: 32.0 1008 | } 1009 | clipping_mode: CLIPPING_MODE_NONE 1010 | clipping_visible: true 1011 | clipping_inverted: false 1012 | alpha: 1.0 1013 | template_node_child: true 1014 | size_mode: SIZE_MODE_MANUAL 1015 | custom_type: 0 1016 | enabled: true 1017 | visible: true 1018 | material: "" 1019 | } 1020 | nodes { 1021 | position { 1022 | x: 0.0 1023 | y: 0.0 1024 | z: 0.0 1025 | w: 1.0 1026 | } 1027 | rotation { 1028 | x: 0.0 1029 | y: 0.0 1030 | z: 0.0 1031 | w: 1.0 1032 | } 1033 | scale { 1034 | x: 1.0 1035 | y: 1.0 1036 | z: 1.0 1037 | w: 1.0 1038 | } 1039 | size { 1040 | x: 200.0 1041 | y: 100.0 1042 | z: 0.0 1043 | w: 1.0 1044 | } 1045 | color { 1046 | x: 1.0 1047 | y: 1.0 1048 | z: 1.0 1049 | w: 1.0 1050 | } 1051 | type: TYPE_TEXT 1052 | blend_mode: BLEND_MODE_ALPHA 1053 | text: "Subscription" 1054 | font: "larryfont" 1055 | id: "subscription/larrylabel" 1056 | xanchor: XANCHOR_NONE 1057 | yanchor: YANCHOR_NONE 1058 | pivot: PIVOT_CENTER 1059 | outline { 1060 | x: 0.0 1061 | y: 0.0 1062 | z: 0.0 1063 | w: 1.0 1064 | } 1065 | shadow { 1066 | x: 1.0 1067 | y: 1.0 1068 | z: 1.0 1069 | w: 1.0 1070 | } 1071 | adjust_mode: ADJUST_MODE_FIT 1072 | line_break: false 1073 | parent: "subscription/larrybutton" 1074 | layer: "" 1075 | inherit_alpha: true 1076 | alpha: 1.0 1077 | outline_alpha: 1.0 1078 | shadow_alpha: 1.0 1079 | overridden_fields: 8 1080 | template_node_child: true 1081 | text_leading: 1.0 1082 | text_tracking: 0.0 1083 | custom_type: 0 1084 | enabled: true 1085 | visible: true 1086 | material: "" 1087 | } 1088 | nodes { 1089 | position { 1090 | x: 320.0 1091 | y: 56.0 1092 | z: 0.0 1093 | w: 1.0 1094 | } 1095 | rotation { 1096 | x: 0.0 1097 | y: 0.0 1098 | z: 0.0 1099 | w: 1.0 1100 | } 1101 | scale { 1102 | x: 1.0 1103 | y: 1.0 1104 | z: 1.0 1105 | w: 1.0 1106 | } 1107 | size { 1108 | x: 200.0 1109 | y: 100.0 1110 | z: 0.0 1111 | w: 1.0 1112 | } 1113 | color { 1114 | x: 1.0 1115 | y: 1.0 1116 | z: 1.0 1117 | w: 1.0 1118 | } 1119 | type: TYPE_TEMPLATE 1120 | id: "pending" 1121 | layer: "" 1122 | inherit_alpha: true 1123 | alpha: 1.0 1124 | template: "/dirtylarry/button.gui" 1125 | template_node_child: false 1126 | custom_type: 0 1127 | enabled: true 1128 | } 1129 | nodes { 1130 | position { 1131 | x: 0.0 1132 | y: 0.0 1133 | z: 0.0 1134 | w: 1.0 1135 | } 1136 | rotation { 1137 | x: 0.0 1138 | y: 0.0 1139 | z: 0.0 1140 | w: 1.0 1141 | } 1142 | scale { 1143 | x: 1.0 1144 | y: 1.0 1145 | z: 1.0 1146 | w: 1.0 1147 | } 1148 | size { 1149 | x: 200.0 1150 | y: 88.0 1151 | z: 0.0 1152 | w: 1.0 1153 | } 1154 | color { 1155 | x: 1.0 1156 | y: 1.0 1157 | z: 1.0 1158 | w: 1.0 1159 | } 1160 | type: TYPE_BOX 1161 | blend_mode: BLEND_MODE_ALPHA 1162 | texture: "button/button_normal" 1163 | id: "pending/larrybutton" 1164 | xanchor: XANCHOR_NONE 1165 | yanchor: YANCHOR_NONE 1166 | pivot: PIVOT_CENTER 1167 | adjust_mode: ADJUST_MODE_FIT 1168 | parent: "pending" 1169 | layer: "" 1170 | inherit_alpha: true 1171 | slice9 { 1172 | x: 32.0 1173 | y: 32.0 1174 | z: 32.0 1175 | w: 32.0 1176 | } 1177 | clipping_mode: CLIPPING_MODE_NONE 1178 | clipping_visible: true 1179 | clipping_inverted: false 1180 | alpha: 1.0 1181 | overridden_fields: 4 1182 | template_node_child: true 1183 | size_mode: SIZE_MODE_MANUAL 1184 | custom_type: 0 1185 | enabled: true 1186 | visible: true 1187 | material: "" 1188 | } 1189 | nodes { 1190 | position { 1191 | x: 0.0 1192 | y: 0.0 1193 | z: 0.0 1194 | w: 1.0 1195 | } 1196 | rotation { 1197 | x: 0.0 1198 | y: 0.0 1199 | z: 0.0 1200 | w: 1.0 1201 | } 1202 | scale { 1203 | x: 1.0 1204 | y: 1.0 1205 | z: 1.0 1206 | w: 1.0 1207 | } 1208 | size { 1209 | x: 200.0 1210 | y: 100.0 1211 | z: 0.0 1212 | w: 1.0 1213 | } 1214 | color { 1215 | x: 1.0 1216 | y: 1.0 1217 | z: 1.0 1218 | w: 1.0 1219 | } 1220 | type: TYPE_TEXT 1221 | blend_mode: BLEND_MODE_ALPHA 1222 | text: "Pending" 1223 | font: "larryfont" 1224 | id: "pending/larrylabel" 1225 | xanchor: XANCHOR_NONE 1226 | yanchor: YANCHOR_NONE 1227 | pivot: PIVOT_CENTER 1228 | outline { 1229 | x: 0.0 1230 | y: 0.0 1231 | z: 0.0 1232 | w: 1.0 1233 | } 1234 | shadow { 1235 | x: 1.0 1236 | y: 1.0 1237 | z: 1.0 1238 | w: 1.0 1239 | } 1240 | adjust_mode: ADJUST_MODE_FIT 1241 | line_break: false 1242 | parent: "pending/larrybutton" 1243 | layer: "" 1244 | inherit_alpha: true 1245 | alpha: 1.0 1246 | outline_alpha: 1.0 1247 | shadow_alpha: 1.0 1248 | overridden_fields: 8 1249 | template_node_child: true 1250 | text_leading: 1.0 1251 | text_tracking: 0.0 1252 | custom_type: 0 1253 | enabled: true 1254 | visible: true 1255 | material: "" 1256 | } 1257 | nodes { 1258 | position { 1259 | x: 40.0 1260 | y: 153.0 1261 | z: 0.0 1262 | w: 1.0 1263 | } 1264 | rotation { 1265 | x: 0.0 1266 | y: 0.0 1267 | z: 0.0 1268 | w: 1.0 1269 | } 1270 | scale { 1271 | x: 1.0 1272 | y: 1.0 1273 | z: 1.0 1274 | w: 1.0 1275 | } 1276 | size { 1277 | x: 200.0 1278 | y: 100.0 1279 | z: 0.0 1280 | w: 1.0 1281 | } 1282 | color { 1283 | x: 1.0 1284 | y: 1.0 1285 | z: 1.0 1286 | w: 1.0 1287 | } 1288 | type: TYPE_TEMPLATE 1289 | id: "chk_finish" 1290 | layer: "" 1291 | inherit_alpha: true 1292 | alpha: 1.0 1293 | template: "/dirtylarry/checkbox_label.gui" 1294 | template_node_child: false 1295 | custom_type: 0 1296 | enabled: true 1297 | } 1298 | nodes { 1299 | position { 1300 | x: 0.0 1301 | y: 0.0 1302 | z: 0.0 1303 | w: 1.0 1304 | } 1305 | rotation { 1306 | x: 0.0 1307 | y: 0.0 1308 | z: 0.0 1309 | w: 1.0 1310 | } 1311 | scale { 1312 | x: 1.0 1313 | y: 1.0 1314 | z: 1.0 1315 | w: 1.0 1316 | } 1317 | size { 1318 | x: 64.0 1319 | y: 68.0 1320 | z: 0.0 1321 | w: 1.0 1322 | } 1323 | color { 1324 | x: 1.0 1325 | y: 1.0 1326 | z: 1.0 1327 | w: 1.0 1328 | } 1329 | type: TYPE_BOX 1330 | blend_mode: BLEND_MODE_ALPHA 1331 | texture: "checkbox/checkbox_normal" 1332 | id: "chk_finish/larrycheckbox" 1333 | xanchor: XANCHOR_NONE 1334 | yanchor: YANCHOR_NONE 1335 | pivot: PIVOT_CENTER 1336 | adjust_mode: ADJUST_MODE_FIT 1337 | parent: "chk_finish" 1338 | layer: "" 1339 | inherit_alpha: true 1340 | slice9 { 1341 | x: 0.0 1342 | y: 0.0 1343 | z: 0.0 1344 | w: 0.0 1345 | } 1346 | clipping_mode: CLIPPING_MODE_NONE 1347 | clipping_visible: true 1348 | clipping_inverted: false 1349 | alpha: 1.0 1350 | template_node_child: true 1351 | size_mode: SIZE_MODE_MANUAL 1352 | custom_type: 0 1353 | enabled: true 1354 | visible: true 1355 | material: "" 1356 | } 1357 | nodes { 1358 | position { 1359 | x: 46.0 1360 | y: 3.0 1361 | z: 0.0 1362 | w: 1.0 1363 | } 1364 | rotation { 1365 | x: 0.0 1366 | y: 0.0 1367 | z: 0.0 1368 | w: 1.0 1369 | } 1370 | scale { 1371 | x: 1.0 1372 | y: 1.0 1373 | z: 1.0 1374 | w: 1.0 1375 | } 1376 | size { 1377 | x: 200.0 1378 | y: 50.0 1379 | z: 0.0 1380 | w: 1.0 1381 | } 1382 | color { 1383 | x: 1.0 1384 | y: 1.0 1385 | z: 1.0 1386 | w: 1.0 1387 | } 1388 | type: TYPE_TEXT 1389 | blend_mode: BLEND_MODE_ALPHA 1390 | text: "Finish" 1391 | font: "larryfont" 1392 | id: "chk_finish/larrylabel" 1393 | xanchor: XANCHOR_NONE 1394 | yanchor: YANCHOR_NONE 1395 | pivot: PIVOT_W 1396 | outline { 1397 | x: 1.0 1398 | y: 1.0 1399 | z: 1.0 1400 | w: 1.0 1401 | } 1402 | shadow { 1403 | x: 1.0 1404 | y: 1.0 1405 | z: 1.0 1406 | w: 1.0 1407 | } 1408 | adjust_mode: ADJUST_MODE_FIT 1409 | line_break: false 1410 | parent: "chk_finish" 1411 | layer: "" 1412 | inherit_alpha: true 1413 | alpha: 1.0 1414 | outline_alpha: 1.0 1415 | shadow_alpha: 1.0 1416 | overridden_fields: 8 1417 | template_node_child: true 1418 | text_leading: 1.0 1419 | text_tracking: 0.0 1420 | custom_type: 0 1421 | enabled: true 1422 | visible: true 1423 | material: "" 1424 | } 1425 | nodes { 1426 | position { 1427 | x: 362.0 1428 | y: 153.0 1429 | z: 0.0 1430 | w: 1.0 1431 | } 1432 | rotation { 1433 | x: 0.0 1434 | y: 0.0 1435 | z: 0.0 1436 | w: 1.0 1437 | } 1438 | scale { 1439 | x: 1.0 1440 | y: 1.0 1441 | z: 1.0 1442 | w: 1.0 1443 | } 1444 | size { 1445 | x: 200.0 1446 | y: 100.0 1447 | z: 0.0 1448 | w: 1.0 1449 | } 1450 | color { 1451 | x: 1.0 1452 | y: 1.0 1453 | z: 1.0 1454 | w: 1.0 1455 | } 1456 | type: TYPE_TEMPLATE 1457 | id: "chk_acknowledge" 1458 | layer: "" 1459 | inherit_alpha: true 1460 | alpha: 1.0 1461 | template: "/dirtylarry/checkbox_label.gui" 1462 | template_node_child: false 1463 | custom_type: 0 1464 | enabled: true 1465 | } 1466 | nodes { 1467 | position { 1468 | x: 0.0 1469 | y: 0.0 1470 | z: 0.0 1471 | w: 1.0 1472 | } 1473 | rotation { 1474 | x: 0.0 1475 | y: 0.0 1476 | z: 0.0 1477 | w: 1.0 1478 | } 1479 | scale { 1480 | x: 1.0 1481 | y: 1.0 1482 | z: 1.0 1483 | w: 1.0 1484 | } 1485 | size { 1486 | x: 64.0 1487 | y: 68.0 1488 | z: 0.0 1489 | w: 1.0 1490 | } 1491 | color { 1492 | x: 1.0 1493 | y: 1.0 1494 | z: 1.0 1495 | w: 1.0 1496 | } 1497 | type: TYPE_BOX 1498 | blend_mode: BLEND_MODE_ALPHA 1499 | texture: "checkbox/checkbox_normal" 1500 | id: "chk_acknowledge/larrycheckbox" 1501 | xanchor: XANCHOR_NONE 1502 | yanchor: YANCHOR_NONE 1503 | pivot: PIVOT_CENTER 1504 | adjust_mode: ADJUST_MODE_FIT 1505 | parent: "chk_acknowledge" 1506 | layer: "" 1507 | inherit_alpha: true 1508 | slice9 { 1509 | x: 0.0 1510 | y: 0.0 1511 | z: 0.0 1512 | w: 0.0 1513 | } 1514 | clipping_mode: CLIPPING_MODE_NONE 1515 | clipping_visible: true 1516 | clipping_inverted: false 1517 | alpha: 1.0 1518 | template_node_child: true 1519 | size_mode: SIZE_MODE_MANUAL 1520 | custom_type: 0 1521 | enabled: true 1522 | visible: true 1523 | material: "" 1524 | } 1525 | nodes { 1526 | position { 1527 | x: 46.0 1528 | y: 3.0 1529 | z: 0.0 1530 | w: 1.0 1531 | } 1532 | rotation { 1533 | x: 0.0 1534 | y: 0.0 1535 | z: 0.0 1536 | w: 1.0 1537 | } 1538 | scale { 1539 | x: 1.0 1540 | y: 1.0 1541 | z: 1.0 1542 | w: 1.0 1543 | } 1544 | size { 1545 | x: 200.0 1546 | y: 50.0 1547 | z: 0.0 1548 | w: 1.0 1549 | } 1550 | color { 1551 | x: 1.0 1552 | y: 1.0 1553 | z: 1.0 1554 | w: 1.0 1555 | } 1556 | type: TYPE_TEXT 1557 | blend_mode: BLEND_MODE_ALPHA 1558 | text: "Acknowledge" 1559 | font: "larryfont" 1560 | id: "chk_acknowledge/larrylabel" 1561 | xanchor: XANCHOR_NONE 1562 | yanchor: YANCHOR_NONE 1563 | pivot: PIVOT_W 1564 | outline { 1565 | x: 1.0 1566 | y: 1.0 1567 | z: 1.0 1568 | w: 1.0 1569 | } 1570 | shadow { 1571 | x: 1.0 1572 | y: 1.0 1573 | z: 1.0 1574 | w: 1.0 1575 | } 1576 | adjust_mode: ADJUST_MODE_FIT 1577 | line_break: false 1578 | parent: "chk_acknowledge" 1579 | layer: "" 1580 | inherit_alpha: true 1581 | alpha: 1.0 1582 | outline_alpha: 1.0 1583 | shadow_alpha: 1.0 1584 | overridden_fields: 8 1585 | template_node_child: true 1586 | text_leading: 1.0 1587 | text_tracking: 0.0 1588 | custom_type: 0 1589 | enabled: true 1590 | visible: true 1591 | material: "" 1592 | } 1593 | material: "/builtins/materials/gui.material" 1594 | adjust_reference: ADJUST_REFERENCE_PARENT 1595 | max_nodes: 512 1596 | -------------------------------------------------------------------------------- /main/main.gui_script: -------------------------------------------------------------------------------- 1 | local dirtylarry = require "dirtylarry/dirtylarry" 2 | 3 | local GOLDBARS_SMALL = "com.defold.iap.goldbar.small" 4 | local GOLDBARS_MEDIUM = "com.defold.iap.goldbar.medium" 5 | local GOLDBARS_LARGE = "com.defold.iap.goldbar.large" 6 | local SUBSCRIPTION = "com.defold.iap.subscription.one" 7 | local NON_CONSUMABLE = "com.defold.iap.removeads" 8 | 9 | local items = { 10 | GOLDBARS_SMALL, 11 | GOLDBARS_MEDIUM, 12 | GOLDBARS_LARGE, 13 | SUBSCRIPTION, 14 | } 15 | 16 | -- mapping between product id and button name 17 | local item_buttons = { 18 | [GOLDBARS_SMALL] = "goldbars_small", 19 | [GOLDBARS_MEDIUM] = "goldbars_medium", 20 | [GOLDBARS_LARGE] = "goldbars_large", 21 | [SUBSCRIPTION] = "subscription", 22 | } 23 | 24 | local available_items = {} 25 | 26 | local LOG = {} 27 | 28 | local function log(fmt, ...) 29 | if not fmt then return end 30 | local line = fmt:format(...) 31 | print(line) 32 | table.insert(LOG, line) 33 | if #LOG > 10 then 34 | table.remove(LOG, 1) 35 | end 36 | local s = table.concat(LOG, "\n") 37 | gui.set_text(gui.get_node("log"), s) 38 | end 39 | 40 | local function process_pending_transactions() 41 | log("iap.process_pending_transactions()") 42 | iap.process_pending_transactions() 43 | end 44 | 45 | local function buy(id) 46 | log("iap.buy() " .. id) 47 | local options = {} 48 | local item = available_items[id] 49 | if item.subscriptions then 50 | local subscription = item.subscriptions[1] 51 | options.token = subscription.token 52 | end 53 | iap.buy(id, options) 54 | end 55 | 56 | local function restore() 57 | log("iap.restore()") 58 | iap.restore() 59 | end 60 | 61 | local function list() 62 | log("iap.list()") 63 | for item, button in pairs(item_buttons) do 64 | gui.set_color(gui.get_node(button.."/larrylabel"), vmath.vector4(1,1,1,0.5)) 65 | end 66 | iap.list(items, function(self, products, error) 67 | if error then 68 | log(error.error) 69 | return 70 | end 71 | 72 | for k,p in pairs(products) do 73 | available_items[p.ident] = p 74 | log("Item %s", p.ident) 75 | pprint(p) 76 | local button = item_buttons[p.ident] 77 | if button then 78 | gui.set_color(gui.get_node(button.."/larrylabel"), vmath.vector4(1,1,1,1)) 79 | else 80 | log("Unable to find button for %s", tostring(p.ident)) 81 | end 82 | end 83 | end) 84 | end 85 | 86 | 87 | local function buy_listener(self, transaction, error) 88 | pprint(transaction) 89 | if error then 90 | log("iap.buy() error %s - %s", tostring(error.error), tostring(error.reason)) 91 | return 92 | end 93 | 94 | if iap.get_provider_id() == iap.PROVIDER_ID_GOOGLE and transaction.ident == NON_CONSUMABLE then 95 | log("iap.buy() ok - google") 96 | gui.set_color(gui.get_node("reset/larrylabel"), vmath.vector4(1,1,1,1)) 97 | product_items["reset"] = transaction 98 | else 99 | log("iap.buy() ok %s", transaction.ident) 100 | if self.finish then 101 | log("iap.finish() %s", transaction.ident) 102 | iap.finish(transaction) 103 | elseif self.acknowledge then 104 | log("iap.acknowledge() %s", transaction.ident) 105 | iap.acknowledge(transaction) 106 | end 107 | end 108 | end 109 | 110 | function init(self) 111 | self.log = {} 112 | self.finish = false 113 | self.acknowledge = false 114 | log("init()") 115 | msg.post(".", "acquire_input_focus") 116 | if not iap then 117 | log("In-App Purchases not supported") 118 | return 119 | end 120 | 121 | list() 122 | iap.set_listener(buy_listener) 123 | end 124 | 125 | function on_input(self, action_id, action) 126 | if action_id then 127 | for item, button in pairs(item_buttons) do 128 | if available_items[item] then 129 | dirtylarry:button(button, action_id, action, function() 130 | buy(item) 131 | end) 132 | end 133 | end 134 | dirtylarry:button("list", action_id, action, function() 135 | list() 136 | end) 137 | dirtylarry:button("restore", action_id, action, function() 138 | restore() 139 | end) 140 | dirtylarry:button("pending", action_id, action, function() 141 | process_pending_transactions() 142 | end) 143 | self.finish = dirtylarry:checkbox("chk_finish", action_id, action, self.finish) 144 | self.acknowledge = dirtylarry:checkbox("chk_acknowledge", action_id, action, self.acknowledge) 145 | end 146 | end 147 | --------------------------------------------------------------------------------