├── .github
└── FUNDING.yml
├── .gitignore
├── .idea
├── .gitignore
├── .name
├── appInsightsSettings.xml
├── compiler.xml
├── gradle.xml
├── kotlinc.xml
├── migrations.xml
├── misc.xml
├── runConfigurations.xml
└── vcs.xml
├── AndroidIAPP
├── .gitignore
├── build.gradle.kts
├── consumer-rules.pro
├── proguard-rules.pro
└── src
│ ├── androidTest
│ └── java
│ │ └── one
│ │ └── allme
│ │ └── plugin
│ │ └── androidiapp
│ │ └── ExampleInstrumentedTest.kt
│ ├── main
│ ├── AndroidManifest.xml
│ └── java
│ │ └── one
│ │ └── allme
│ │ └── plugin
│ │ └── androidiapp
│ │ ├── AndroidIAPP.kt
│ │ └── utils
│ │ └── IAPP_utils.kt
│ └── test
│ └── java
│ └── one
│ └── allme
│ └── plugin
│ └── androidiapp
│ └── ExampleUnitTest.kt
├── LICENSE
├── README.md
├── build.gradle.kts
├── examples
├── billing_example.gd
├── details_inapp.json
├── details_subscription.json
├── payment-method.png
└── purchase_updated_inapp.json
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
└── settings.gradle.kts
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4 | patreon: # codewithmax
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # codewithmax
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | buy_me_a_coffee: codewithmax
14 | custom: ['https://boosty.to/codewithmax']
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | /local.properties
4 | /.idea/caches
5 | /.idea/libraries
6 | /.idea/modules.xml
7 | /.idea/workspace.xml
8 | /.idea/navEditor.xml
9 | /.idea/assetWizardSettings.xml
10 | .DS_Store
11 | /build
12 | /captures
13 | .externalNativeBuild
14 | .cxx
15 | local.properties
16 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 |
--------------------------------------------------------------------------------
/.idea/.name:
--------------------------------------------------------------------------------
1 | GoogleIAPP
--------------------------------------------------------------------------------
/.idea/appInsightsSettings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
31 |
32 |
--------------------------------------------------------------------------------
/.idea/compiler.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/gradle.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
19 |
20 |
--------------------------------------------------------------------------------
/.idea/kotlinc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/migrations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/.idea/runConfigurations.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/AndroidIAPP/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/AndroidIAPP/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.library)
3 | alias(libs.plugins.jetbrains.kotlin.android)
4 | }
5 |
6 | android {
7 | signingConfigs {
8 | create("release") {
9 | }
10 | }
11 | namespace = "one.allme.plugin.androidiapp"
12 | compileSdk = 35
13 |
14 | defaultConfig {
15 | minSdk = 24
16 |
17 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
18 | consumerProguardFiles("consumer-rules.pro")
19 | // targetSdk = 35
20 | // targetSdk = 35
21 | }
22 |
23 | buildTypes {
24 | release {
25 | isMinifyEnabled = false
26 | proguardFiles(
27 | getDefaultProguardFile("proguard-android-optimize.txt"),
28 | "proguard-rules.pro"
29 | )
30 | }
31 | }
32 | compileOptions {
33 | sourceCompatibility = JavaVersion.VERSION_17
34 | targetCompatibility = JavaVersion.VERSION_17
35 | }
36 | kotlinOptions {
37 | jvmTarget = "17"
38 | }
39 | buildToolsVersion = "36.0.0"
40 | ndkVersion = "28.0.13004108"
41 | }
42 |
43 | dependencies {
44 |
45 | implementation(libs.androidx.core.ktx)
46 | implementation(libs.androidx.appcompat)
47 | implementation(libs.material)
48 | implementation(libs.billing.ktx)
49 | implementation(libs.godotengine.godot)
50 | testImplementation(libs.junit)
51 | androidTestImplementation(libs.androidx.junit)
52 | androidTestImplementation(libs.androidx.espresso.core)
53 | }
--------------------------------------------------------------------------------
/AndroidIAPP/consumer-rules.pro:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-with-max/godot-google-play-iapp/b40ecb140a7d22a8923d543d4c8a669b68bf5dbf/AndroidIAPP/consumer-rules.pro
--------------------------------------------------------------------------------
/AndroidIAPP/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/AndroidIAPP/src/androidTest/java/one/allme/plugin/androidiapp/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package one.allme.plugin.androidiapp
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("one.allme.plugin.androidiapp.test", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/AndroidIAPP/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
16 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/AndroidIAPP/src/main/java/one/allme/plugin/androidiapp/AndroidIAPP.kt:
--------------------------------------------------------------------------------
1 | package one.allme.plugin.androidiapp
2 |
3 | import one.allme.plugin.androidiapp.utils.IAPP_utils
4 |
5 | import android.util.Log
6 | import android.widget.Toast
7 | import com.android.billingclient.api.AcknowledgePurchaseParams
8 | import com.android.billingclient.api.BillingClient
9 | import com.android.billingclient.api.BillingClient.ProductType
10 | import com.android.billingclient.api.BillingClientStateListener
11 | import com.android.billingclient.api.BillingFlowParams
12 | import com.android.billingclient.api.BillingResult
13 | import com.android.billingclient.api.ConsumeParams
14 | import com.android.billingclient.api.Purchase
15 | import com.android.billingclient.api.PurchasesUpdatedListener
16 | import com.android.billingclient.api.QueryProductDetailsParams
17 | import com.android.billingclient.api.QueryPurchasesParams
18 | //import com.android.billingclient.api.consumePurchase
19 | //import kotlinx.coroutines.Dispatchers
20 | //import kotlinx.coroutines.withContext
21 | //import kotlin.coroutines.resume
22 | //import kotlin.coroutines.suspendCoroutine
23 | import org.godotengine.godot.Godot
24 | import org.godotengine.godot.Dictionary
25 | import org.godotengine.godot.plugin.GodotPlugin
26 | import org.godotengine.godot.plugin.SignalInfo
27 | import org.godotengine.godot.plugin.UsedByGodot
28 |
29 |
30 | class AndroidIAPP(godot: Godot?): GodotPlugin(godot),
31 | PurchasesUpdatedListener,
32 | BillingClientStateListener {
33 |
34 |
35 | // private val purchasesUpdatedListener =
36 | // PurchasesUpdatedListener { billingResult, purchases -> }
37 | // private val acknowledgePurchaseResponseListener: AcknowledgePurchaseResponseListener = AcknowledgePurchaseResponseListener { billingResult -> }
38 |
39 |
40 | private val billingClient: BillingClient = BillingClient
41 | .newBuilder(activity!!)
42 | .enablePendingPurchases()
43 | .setListener(this)
44 | .build()
45 |
46 |
47 | // Signals
48 | // Echo
49 | private val helloResponseSignal = SignalInfo(
50 | "helloResponse", String::class.java,
51 | )
52 |
53 | // Information
54 | private val startConnectionSignal = SignalInfo(
55 | "startConnection",
56 | )
57 | private val connectedSignal = SignalInfo(
58 | "connected",
59 | )
60 | private val disconnectedSignal = SignalInfo(
61 | "disconnected",
62 | )
63 |
64 | // Query purchases
65 | private val queryPurchasesSignal = SignalInfo(
66 | "query_purchases", Dictionary::class.java,
67 | )
68 | private val queryPurchasesErrorSignal = SignalInfo(
69 | "query_purchases_error", Dictionary::class.java,
70 | )
71 |
72 | // Query product details
73 | private val queryProductDetailsSignal = SignalInfo(
74 | "query_product_details", Dictionary::class.java,
75 | )
76 | private val queryProductDetailsErrorSignal = SignalInfo(
77 | "query_product_details_error", Dictionary::class.java,
78 | )
79 |
80 | // Purchase processing
81 | private val purchaseSignal = SignalInfo(
82 | "purchase", Dictionary::class.java,
83 | )
84 | private val purchaseErrorSignal = SignalInfo(
85 | "purchase_error", Dictionary::class.java,
86 | )
87 |
88 | // Purchase updating
89 | private val purchaseUpdatedSignal = SignalInfo(
90 | "purchase_updated", Dictionary::class.java,
91 | )
92 | private val purchaseCancelledSignal = SignalInfo(
93 | "purchase_cancelled", Dictionary::class.java,
94 | )
95 | private val purchaseUpdatedErrorSignal = SignalInfo(
96 | "purchase_update_error", Dictionary::class.java,
97 | )
98 |
99 | // Purchases consuming
100 | private val purchaseConsumedSignal = SignalInfo(
101 | "purchase_consumed", Dictionary::class.java,
102 | )
103 | private val purchaseConsumedErrorSignal = SignalInfo(
104 | "purchase_consumed_error", Dictionary::class.java,
105 | )
106 |
107 | // Purchases acknowledge
108 | private val purchaseAcknowledgedSignal = SignalInfo(
109 | "purchase_acknowledged", Dictionary::class.java,
110 | )
111 | private val purchaseAcknowledgedErrorSignal = SignalInfo(
112 | "purchase_acknowledged_error", Dictionary::class.java,
113 | )
114 |
115 |
116 | override fun getPluginSignals(): Set {
117 | Log.i(pluginName, "Registering plugin signals")
118 | return setOf(
119 | helloResponseSignal,
120 | startConnectionSignal,
121 | connectedSignal,
122 | disconnectedSignal,
123 | queryPurchasesSignal,
124 | queryPurchasesErrorSignal,
125 | queryProductDetailsSignal,
126 | queryProductDetailsErrorSignal,
127 | purchaseSignal,
128 | purchaseErrorSignal,
129 | purchaseUpdatedSignal,
130 | purchaseCancelledSignal,
131 | purchaseUpdatedErrorSignal,
132 | purchaseConsumedSignal,
133 | purchaseConsumedErrorSignal,
134 | purchaseAcknowledgedSignal,
135 | purchaseAcknowledgedErrorSignal
136 | )
137 | }
138 |
139 |
140 | override fun getPluginName(): String {
141 | return ("AndroidIAPP")
142 | }
143 |
144 |
145 | @get:UsedByGodot
146 | val isReady: Boolean
147 | get() {
148 | Log.v(pluginName, "Is ready: ${billingClient.isReady}")
149 | return billingClient.isReady
150 | }
151 |
152 |
153 | // TODO Type mismatch. Check if it is possible/useful
154 | // https://developer.android.com/reference/com/android/billingclient/api/BillingClient.ConnectionState
155 | // @get:UsedByGodot
156 | // val connectionState: Int
157 | // get() {
158 | // Log.v(pluginName, "Connection state: ${billingClient.connectionState}")
159 | // return billingClient.connectionState
160 | // }
161 |
162 |
163 | // Just say hello func
164 | @UsedByGodot
165 | fun sayHello(says: String = "Hello from AndroidIAPP plugin") {
166 | runOnUiThread {
167 | Toast.makeText(activity, says, Toast.LENGTH_LONG).show()
168 | emitSignal(helloResponseSignal.name, says)
169 | Log.i(pluginName, says)
170 | }
171 | }
172 |
173 |
174 | @UsedByGodot
175 | private fun startConnection() {
176 | billingClient.startConnection(this)
177 | emitSignal(startConnectionSignal.name)
178 | Log.v(pluginName, "Billing service start connection")
179 | }
180 |
181 |
182 | override fun onBillingServiceDisconnected() {
183 | emitSignal(disconnectedSignal.name)
184 | Log.v(pluginName, "Billing service disconnected")
185 | // Try to restart the connection on the next request to
186 | // Google Play by calling the startConnection() method.
187 | }
188 |
189 |
190 | override fun onBillingSetupFinished(billingResult: BillingResult) {
191 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
192 | emitSignal(connectedSignal.name)
193 | Log.v(pluginName, "Billing service connected")
194 | // The BillingClient is ready. You can query purchases here.
195 | }
196 | }
197 |
198 |
199 | // https://developer.android.com/reference/com/android/billingclient/api/BillingClient.ProductType
200 | // productType should be ProductType.INAPP or ProductType.SUBS
201 | // TODO Default value of productType not setting. Need function overloading for fixing
202 | @UsedByGodot
203 | private fun queryPurchases(productType: String = ProductType.INAPP) {
204 | val params = QueryPurchasesParams
205 | .newBuilder()
206 | .setProductType(productType)
207 | .build()
208 | billingClient.queryPurchasesAsync(params) { billingResult, purchaseList ->
209 | val returnDict = Dictionary() // from Godot type Dictionary
210 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
211 | Log.v(pluginName, "Purchases found")
212 | returnDict["response_code"] = billingResult.responseCode
213 | returnDict["purchases_list"] = IAPP_utils.convertPurchasesListToArray(purchaseList)
214 | emitSignal(queryPurchasesSignal.name, returnDict)
215 | } else {
216 | Log.v(pluginName, "No purchase found")
217 | returnDict["response_code"] = billingResult.responseCode
218 | returnDict["debug_message"] = billingResult.debugMessage
219 | returnDict["purchases_list"] = null
220 | emitSignal(queryPurchasesErrorSignal.name, returnDict)
221 | }
222 | }
223 | }
224 |
225 | // Use kotlin functions for queryPurchases
226 | // Kotlin coroutines are not supported in the current version of Godot (4.2).
227 | // java.lang.NoSuchMethodError: no non-static method "Lone/allme/plugin/androidiapp/AndroidIAPP;.queryPurchasesAsync
228 | // Use this feature in later versions.
229 | // @UsedByGodot
230 | // suspend fun queryPurchasesAsync() {
231 | // val params = QueryPurchasesParams.newBuilder()
232 | // .setProductType(ProductType.SUBS)
233 | //
234 | // // uses queryPurchasesAsync Kotlin extension function
235 | // val purchasesResult = billingClient.queryPurchasesAsync(params.build())
236 | //
237 | // // check purchasesResult.billingResult
238 | // // process returned purchasesResult.purchasesList, e.g. display the plans user owns
239 | // }
240 |
241 |
242 | @UsedByGodot
243 | private fun queryProductDetails(
244 | listOfProductsIDs: Array, productType: String = ProductType.INAPP) {
245 | val products = ArrayList()
246 |
247 | for (productID in listOfProductsIDs) {
248 | products.add(QueryProductDetailsParams.Product.newBuilder()
249 | .setProductId(productID)
250 | .setProductType(productType)
251 | .build())
252 | }
253 |
254 | val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
255 | .setProductList(products)
256 | .build()
257 |
258 | billingClient.queryProductDetailsAsync(queryProductDetailsParams) {
259 | billingResult, productDetailsList ->
260 | val returnDict = Dictionary() // from Godot type Dictionary
261 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
262 | Log.v(pluginName, "Product details found")
263 | returnDict["response_code"] = billingResult.responseCode
264 | returnDict["product_details_list"] = IAPP_utils
265 | .convertProductDetailsListToArray(productDetailsList)
266 | emitSignal(queryProductDetailsSignal.name, returnDict)
267 | } else {
268 | Log.v(pluginName, "No product details found")
269 | returnDict["response_code"] = billingResult.responseCode
270 | returnDict["debug_message"] = billingResult.debugMessage
271 | emitSignal(queryProductDetailsErrorSignal.name, returnDict)
272 | }
273 | }
274 |
275 | }
276 |
277 | @UsedByGodot
278 | private fun purchase(listOfProductsIDs: Array,
279 | isOfferPersonalized: Boolean) {
280 |
281 | val activity = activity!!
282 |
283 | // There can be only one!
284 | val productID = listOfProductsIDs[0]
285 | Log.v(pluginName, "Starting purchase flow for $productID product")
286 |
287 | // Before launching purchase flow, query product details.
288 | val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
289 | .setProductList(
290 | listOf(
291 | QueryProductDetailsParams.Product.newBuilder()
292 | .setProductId(productID)
293 | .setProductType(ProductType.INAPP)
294 | .build()
295 | )
296 | )
297 | .build()
298 | // Querying details for purchasing product.
299 | billingClient.queryProductDetailsAsync(queryProductDetailsParams) {
300 | queryDetailsResult, productDetailsList ->
301 | val returnDict = Dictionary() // from Godot type Dictionary
302 | if (queryDetailsResult.responseCode != BillingClient.BillingResponseCode.OK) {
303 | // Error getting details, say something to godot users.
304 | Log.v(pluginName, "Error getting $productID product details")
305 | returnDict["response_code"] = queryDetailsResult.responseCode
306 | returnDict["debug_message"] = queryDetailsResult.debugMessage
307 | emitSignal(queryProductDetailsErrorSignal.name, returnDict)
308 | } else {
309 | // Product details found successfully. Launch purchase flow.
310 | Log.v(pluginName, "Details for product $productID found")
311 | val productDetailsParamsList = listOf(
312 | BillingFlowParams.ProductDetailsParams.newBuilder().apply {
313 | // There can be only one!
314 | setProductDetails(productDetailsList[0])
315 | // val offerDetails = productDetailsList[0].subscriptionOfferDetails?.get(0)
316 | // Log.v(pluginName, "Offer details token: ${offerDetails?.offerToken}")
317 | // if (offerDetails != null) {
318 | // // Optional, setting offer token only for subscriptions.
319 | // setOfferToken(offerDetails.offerToken)
320 | // }
321 | }.build())
322 | val flowParams = BillingFlowParams
323 | .newBuilder()
324 | .setProductDetailsParamsList(productDetailsParamsList)
325 | // https://developer.android.com/google/play/billing/integrate#personalized-price
326 | .setIsOfferPersonalized(isOfferPersonalized)
327 | .build()
328 |
329 | // Running purchase flow.
330 | val purchasingResult = billingClient.launchBillingFlow(activity, flowParams)
331 | if (purchasingResult.responseCode == BillingClient.BillingResponseCode.OK) {
332 | // Purchasing successfully launched.
333 | // Result will be received in onPurchasesUpdated() method.
334 | Log.v(pluginName, "Product $productID purchasing launched successfully")
335 | } else {
336 | // Error purchasing. Says something to Godot users.
337 | Log.v(pluginName, "$productID purchasing failed")
338 | returnDict["response_code"] = purchasingResult.responseCode
339 | returnDict["debug_message"] = purchasingResult.debugMessage
340 | returnDict["product_id"] = productID
341 | emitSignal(purchaseErrorSignal.name, returnDict)
342 | }
343 | }
344 | }
345 | }
346 |
347 |
348 | // Process subscriptions
349 | @UsedByGodot
350 | private fun subscribe(listOfProductsIDs: Array,
351 | basePlanIDs: Array,
352 | isOfferPersonalized: Boolean) {
353 |
354 | val activity = activity!!
355 |
356 | // There can be only one!
357 | val productID = listOfProductsIDs[0]
358 | val basePlanID = basePlanIDs[0]
359 | Log.v(pluginName, "Starting purchase flow for $productID subscription")
360 |
361 | // Before launching purchase flow, query product details.
362 | val queryProductDetailsParams = QueryProductDetailsParams.newBuilder()
363 | .setProductList(
364 | listOf(
365 | QueryProductDetailsParams.Product.newBuilder()
366 | .setProductId(productID)
367 | .setProductType(ProductType.SUBS)
368 | .build()
369 | )
370 | )
371 | .build()
372 | // Querying details for subscription.
373 | billingClient.queryProductDetailsAsync(queryProductDetailsParams) {
374 | queryDetailsResult, productDetailsList ->
375 | val returnDict = Dictionary() // from Godot type Dictionary
376 | if (queryDetailsResult.responseCode != BillingClient.BillingResponseCode.OK) {
377 | // Error getting details, say something to godot users.
378 | Log.v(pluginName, "Error getting $productID subscription details")
379 | returnDict["response_code"] = queryDetailsResult.responseCode
380 | returnDict["debug_message"] = queryDetailsResult.debugMessage
381 | emitSignal(queryProductDetailsErrorSignal.name, returnDict)
382 | } else {
383 | // Product details found successfully. Launch purchase flow.
384 | Log.v(pluginName, "Details for subscription $productID found")
385 | val productDetailsParamsList = listOf(
386 | BillingFlowParams.ProductDetailsParams.newBuilder().apply {
387 | // There can be only one!
388 | setProductDetails(productDetailsList[0])
389 | val offerDetails =
390 | productDetailsList[0].subscriptionOfferDetails?.firstOrNull { it.basePlanId == basePlanID }
391 | if (offerDetails != null) {
392 | setOfferToken(offerDetails.offerToken)
393 | } else {
394 | Log.v(
395 | pluginName,
396 | "Base Plan ID $basePlanID not found in $productID subscription"
397 | )
398 | returnDict["debug_message"] =
399 | "Base Plan ID $basePlanID not found in $productID subscription"
400 | emitSignal(purchaseErrorSignal.name, returnDict)
401 | }
402 | }.build()
403 | )
404 | val flowParams = BillingFlowParams
405 | .newBuilder()
406 | .setProductDetailsParamsList(productDetailsParamsList)
407 | // https://developer.android.com/google/play/billing/integrate#personalized-price
408 | .setIsOfferPersonalized(isOfferPersonalized)
409 | .build()
410 |
411 | // Running subscription flow.
412 | val purchasingResult = billingClient.launchBillingFlow(activity, flowParams)
413 | if (purchasingResult.responseCode == BillingClient.BillingResponseCode.OK) {
414 | // Subscription successfully launched.
415 | // Result will be received in onPurchasesUpdated() method.
416 | Log.v(
417 | pluginName,
418 | "Subscription $productID with $basePlanID purchasing launched successfully"
419 | )
420 | } else {
421 | // Error purchasing. Says something to Godot users.
422 | Log.v(pluginName, "$productID purchasing failed")
423 | returnDict["response_code"] = purchasingResult.responseCode
424 | returnDict["debug_message"] = purchasingResult.debugMessage
425 | returnDict["product_id"] = productID
426 | returnDict["base_plan_id"] = basePlanID
427 | emitSignal(purchaseErrorSignal.name, returnDict)
428 | }
429 | }
430 | }
431 | }
432 |
433 |
434 | override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) {
435 | val returnDict = Dictionary() // from Godot type Dictionary
436 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
437 | // All good, some purchase(s) have been updated
438 | Log.v(pluginName, "Purchases updated successfully")
439 | returnDict["response_code"] = billingResult.responseCode
440 | returnDict["purchases_list"] = IAPP_utils.convertPurchasesListToArray(purchases)
441 | emitSignal(purchaseUpdatedSignal.name, returnDict)
442 | } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) {
443 | Log.v(pluginName, "User canceled purchase updating")
444 | returnDict["response_code"] = billingResult.responseCode
445 | returnDict["debug_message"] = billingResult.debugMessage
446 | emitSignal(purchaseCancelledSignal.name, returnDict)
447 | } else {
448 | // Purchasing errors. Says something to Godot users.
449 | Log.v(pluginName, "Error purchase updating, response code: ${billingResult.responseCode}")
450 | returnDict["response_code"] = billingResult.responseCode
451 | returnDict["debug_message"] = billingResult.debugMessage
452 | emitSignal(purchaseUpdatedErrorSignal.name, returnDict)
453 | }
454 | }
455 |
456 |
457 | // Consume purchase using Kotlin coroutines
458 | // Kotlin coroutines are not supported in the current version of Godot (4.2).
459 | // java.lang.NoSuchMethodError: no non-static method "Lone/allme/plugin/androidiapp/AndroidIAPP;.consumePurchaseKT
460 | // Use this feature in later versions.
461 | //
462 | // @UsedByGodot
463 | // suspend fun consumePurchaseKT(purchaseTokenLocal: String) {
464 | // val consumeParams = ConsumeParams.newBuilder()
465 | // .setPurchaseToken(purchaseTokenLocal)
466 | // .build()
467 | // val consumeResult = withContext(Dispatchers.IO) {
468 | // billingClient.consumePurchase(consumeParams) }
469 | // val returnDict = Dictionary() // from Godot type Dictionary
470 | // if (consumeResult.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
471 | // Log.v(pluginName, "Purchase ${consumeResult.purchaseToken} acknowledged successfully")
472 | // returnDict["response_code"] = consumeResult.billingResult.responseCode
473 | // returnDict["purchase_token"] = consumeResult.purchaseToken
474 | // emitSignal(purchaseConsumedSignal.name, returnDict)
475 | // } else {
476 | // Log.v(pluginName, "Error purchase acknowledging, response code: ${consumeResult.billingResult.responseCode}")
477 | // returnDict["response_code"] = consumeResult.billingResult.responseCode
478 | // returnDict["debug_message"] = consumeResult.billingResult.debugMessage
479 | // emitSignal(purchaseConsumedSignal.name, returnDict)
480 | // }
481 | // }
482 |
483 |
484 | // Acknowledge purchase using Kotlin coroutines
485 | // Kotlin coroutines are not supported in the current version of Godot (4.2).
486 | // " java.lang.NoSuchMethodError: no non-static method "
487 | // Use this feature in later versions.
488 | //
489 | // @UsedByGodot
490 | // suspend fun acknowledgePurchaseKT(purchaseTokenLocal: String) {
491 | // val returnDict = Dictionary() // from Godot type Dictionary
492 | // val acknowledgeParams = AcknowledgePurchaseParams.newBuilder()
493 | // .setPurchaseToken(purchaseTokenLocal)
494 | // .build()
495 | // try {
496 | // withContext(Dispatchers.IO) {
497 | // val acknowledgeResult = suspendCoroutine { continuation ->
498 | // billingClient.acknowledgePurchase(acknowledgeParams) { billingResult ->
499 | // continuation.resume(billingResult)
500 | // }
501 | // }
502 | //
503 | // if (acknowledgeResult.responseCode == BillingClient.BillingResponseCode.OK) {
504 | // Log.v(pluginName, "Purchase $purchaseTokenLocal acknowledged successfully")
505 | // returnDict["response_code"] = acknowledgeResult.responseCode
506 | // returnDict["purchase_token"] = purchaseTokenLocal
507 | // emitSignal(purchaseAcknowledgedSignal.name, returnDict)
508 | // } else {
509 | // Log.v(pluginName, "Error purchase acknowledging, response code: ${acknowledgeResult.responseCode}")
510 | // returnDict["response_code"] = acknowledgeResult.responseCode
511 | // returnDict["debug_message"] = acknowledgeResult.debugMessage
512 | // emitSignal(purchaseAcknowledgedErrorSignal.name, returnDict)
513 | // }
514 | // }
515 | // } catch (e: Exception) {
516 | // Log.v (pluginName, "Error purchase acknowledging, exception: ${e.message}")
517 | // returnDict["response_code"] = BillingClient.BillingResponseCode.ERROR
518 | // returnDict["debug_message"] = e.message
519 | // emitSignal(purchaseAcknowledgedErrorSignal.name, returnDict)
520 | // }
521 | // }
522 |
523 |
524 |
525 | // Java style consuming purchase
526 | @UsedByGodot
527 | fun consumePurchase(purchaseTokenLocal: String) {
528 | //Log.v(pluginName, "Consuming purchase $savedPurchaseToken")
529 | val consumeParams = ConsumeParams.newBuilder()
530 | .setPurchaseToken(purchaseTokenLocal)
531 | .build()
532 | billingClient.consumeAsync(consumeParams) { billingResult, purchaseToken ->
533 | val returnDict = Dictionary() // from Godot type Dictionary
534 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
535 | Log.v(pluginName, "Purchase consumed successfully: $purchaseToken")
536 | returnDict["response_code"] = billingResult.responseCode
537 | returnDict["purchase_token"] = purchaseToken
538 | emitSignal(purchaseConsumedSignal.name, returnDict)
539 | } else {
540 | Log.v(pluginName, "Error purchase consuming, response code: ${billingResult.responseCode}")
541 | returnDict["response_code"] = billingResult.responseCode
542 | returnDict["debug_message"] = billingResult.debugMessage
543 | returnDict["purchase_token"] = purchaseToken
544 | emitSignal(purchaseConsumedErrorSignal.name, returnDict)
545 | }
546 | }
547 | }
548 |
549 |
550 | // Java style acknowledging purchase
551 | @UsedByGodot
552 | private fun acknowledgePurchase(purchaseToken: String) {
553 | val acknowledgePurchaseParams = AcknowledgePurchaseParams
554 | .newBuilder()
555 | .setPurchaseToken(purchaseToken)
556 | .build()
557 | billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
558 | val returnDict = Dictionary() // from Godot type Dictionary
559 | if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
560 | Log.v(pluginName, "Purchase acknowledged successfully: $purchaseToken")
561 | returnDict["response_code"] = billingResult.responseCode
562 | returnDict["purchase_token"] = purchaseToken
563 | emitSignal(purchaseAcknowledgedSignal.name, returnDict)
564 | } else {
565 | Log.v(pluginName, "Error purchase acknowledging, response code: ${billingResult.responseCode}")
566 | returnDict["response_code"] = billingResult.responseCode
567 | returnDict["debug_message"] = billingResult.debugMessage
568 | returnDict["purchase_token"] = purchaseToken
569 | emitSignal(purchaseAcknowledgedErrorSignal.name, returnDict)
570 | }
571 | }
572 | }
573 |
574 |
575 | }
576 |
577 |
578 |
579 |
580 |
581 |
--------------------------------------------------------------------------------
/AndroidIAPP/src/main/java/one/allme/plugin/androidiapp/utils/IAPP_utils.kt:
--------------------------------------------------------------------------------
1 | package one.allme.plugin.androidiapp.utils
2 |
3 | import com.android.billingclient.api.BillingClient
4 | import com.android.billingclient.api.ProductDetails
5 | import com.android.billingclient.api.Purchase
6 | import org.godotengine.godot.Dictionary
7 |
8 |
9 | object IAPP_utils {
10 |
11 | // Convert list of purchases to array
12 | // https://developer.android.com/reference/com/android/billingclient/api/PurchasesUpdatedListener
13 | fun convertPurchasesListToArray(purchasesList: List): Array {
14 | val purchasesArray = arrayOfNulls(purchasesList.size)
15 | for (i in purchasesList.indices) {
16 | purchasesArray[i] = convertPurchaseToDictionary(purchasesList[i])
17 | }
18 | return purchasesArray
19 | }
20 |
21 | // https://developer.android.com/reference/com/android/billingclient/api/Purchase
22 | private fun convertPurchaseToDictionary(purchase: Purchase): Dictionary {
23 | val dictionary = Dictionary() // from Godot type Dictionary
24 | // dictionary["equals"] = purchase.equals() // TODO check this method
25 | dictionary["account_identifiers"] = purchase.accountIdentifiers
26 | dictionary["developer_payload"] = purchase.developerPayload
27 | dictionary["order_id"] = purchase.orderId
28 | dictionary["original_json"] = purchase.originalJson
29 | dictionary["package_name"] = purchase.packageName
30 | dictionary["pending_purchase_update"] = purchase.pendingPurchaseUpdate
31 | dictionary["products"] = convertPurchaseProductsIdsListToArray(purchase.products) // list of string
32 | dictionary["purchase_state"] = purchase.purchaseState
33 | dictionary["purchase_time"] = purchase.purchaseTime
34 | dictionary["purchase_token"] = purchase.purchaseToken
35 | dictionary["quantity"] = purchase.quantity
36 | dictionary["signature"] = purchase.signature
37 | dictionary["hash_code"] = purchase.hashCode()
38 | dictionary["is_acknowledged"] = purchase.isAcknowledged
39 | dictionary["is_auto_renewing"] = purchase.isAutoRenewing
40 | dictionary["to_string"] = purchase.toString()
41 | return dictionary
42 | }
43 |
44 |
45 | // Returns the product Ids.
46 | // Convert list of purchase products to array
47 | // https://developer.android.com/reference/com/android/billingclient/api/Purchase#getProducts()
48 | private fun convertPurchaseProductsIdsListToArray(purchaseProductsList: List): Array {
49 | val purchaseProductsArray = arrayOfNulls(purchaseProductsList.size)
50 | for (i in purchaseProductsList.indices) {
51 | purchaseProductsArray[i] = purchaseProductsList[i]
52 | }
53 | return purchaseProductsArray
54 | }
55 |
56 |
57 | // Product Details utils below
58 | // Convert list of detailed product details to array
59 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetailsResponseListener
60 | // Called from: queryProductDetails
61 | fun convertProductDetailsListToArray(productDetailsList: List): Array {
62 | val productDetailsArray = arrayOfNulls(productDetailsList.size)
63 | for (i in productDetailsList.indices) {
64 | productDetailsArray[i] = convertProductDetailsToDictionary(productDetailsList[i])
65 | }
66 | return productDetailsArray
67 | }
68 |
69 |
70 | // Convert product details to dictionary
71 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails
72 | private fun convertProductDetailsToDictionary(productsDetails: ProductDetails): Dictionary {
73 | val dictionary = Dictionary() // from Godot type Dictionary
74 | dictionary["description"] = productsDetails.description
75 | dictionary["name"] = productsDetails.name
76 | dictionary["product_id"] = productsDetails.productId
77 | dictionary["product_type"] = productsDetails.productType
78 | dictionary["title"] = productsDetails.title
79 | dictionary["hash_code"] = productsDetails.hashCode()
80 | dictionary["to_string"] = productsDetails.toString()
81 | if (productsDetails.productType == BillingClient.ProductType.INAPP) {
82 | dictionary["one_time_purchase_offer_details"] = convertPurchaseOfferToDict(productsDetails.oneTimePurchaseOfferDetails)
83 | } else if (productsDetails.productType == BillingClient.ProductType.SUBS) {
84 | dictionary["subscription_offer_details"] = convertSubscriptionsDetailsListToArray(productsDetails.subscriptionOfferDetails)
85 | }
86 | return dictionary
87 | }
88 |
89 |
90 | // (INAPP)
91 | // Convert One Time Purchase Offer Details to Dictionary
92 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.OneTimePurchaseOfferDetails
93 | private fun convertPurchaseOfferToDict(offerDetails: ProductDetails.OneTimePurchaseOfferDetails?): Dictionary {
94 | val dictionary = Dictionary() // from Godot type Dictionary
95 | if (offerDetails != null) {
96 | dictionary["formatted_price"] = offerDetails.formattedPrice
97 | dictionary["price_currency_code"] = offerDetails.priceCurrencyCode
98 | dictionary["price_amount_micros"] = offerDetails.priceAmountMicros
99 | }
100 | return dictionary
101 | }
102 |
103 |
104 | // (SUBS)
105 | // Convert list of subscriptions offers to array
106 | // yeah, lot of not Null checks :(
107 | private fun convertSubscriptionsDetailsListToArray(subscriptionsOffersList: List?): Array? {
108 | val subscriptionsOffersArray = subscriptionsOffersList?.let { arrayOfNulls(it.size) }
109 | if (subscriptionsOffersList != null) {
110 | for (i in subscriptionsOffersList.indices) {
111 | subscriptionsOffersArray?.set(i, convertSubscriptionDetailsToDictionary(subscriptionsOffersList[i]))
112 | }
113 | }
114 | return subscriptionsOffersArray
115 | }
116 |
117 | // Convert subscription offer details to dictionary
118 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.SubscriptionOfferDetails
119 | private fun convertSubscriptionDetailsToDictionary(offerDetails: ProductDetails.SubscriptionOfferDetails): Dictionary {
120 | val dictionary = Dictionary() // from Godot type Dictionary
121 | dictionary["base_plan_id"] = offerDetails.basePlanId
122 | dictionary["installment_plan_details"] = convertInstallementPlanDetailsToArray(offerDetails.installmentPlanDetails)
123 | dictionary["offer_id"] = offerDetails.offerId
124 | dictionary["offer_tags"] = convertOfferTagsListToArray(offerDetails.offerTags) // list of String
125 | dictionary["offer_token"] = offerDetails.offerToken
126 | dictionary["pricing_phases"] = convertPricingPhasesListToArray(offerDetails.pricingPhases.pricingPhaseList)
127 | return dictionary
128 | }
129 |
130 | // Convert list of subscriptions offers to array
131 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.SubscriptionOfferDetails#getOfferTags()
132 | // yeah, lot of not Null checks :(
133 | private fun convertOfferTagsListToArray(offerTagsList: List?): Array? {
134 | val offerTagsArray = offerTagsList?.let { arrayOfNulls(it.size) }
135 | if (offerTagsList != null){
136 | for (i in offerTagsList.indices) {
137 | offerTagsArray?.set(i, (offerTagsList[i]))
138 | }
139 | }
140 | return offerTagsArray
141 | }
142 |
143 |
144 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.PricingPhases
145 | private fun convertPricingPhasesListToArray(phasesList: MutableList?): Array? {
146 | val phasesArray = phasesList?.let { arrayOfNulls(it.size) }
147 | if (phasesList != null) {
148 | for (i in phasesList.indices) {
149 | phasesArray?.set(i, convertPricingPhaseToDictionary(phasesList[i]))
150 | }
151 | }
152 | return phasesArray
153 | }
154 |
155 |
156 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.PricingPhase
157 | private fun convertPricingPhaseToDictionary(phase: ProductDetails.PricingPhase): Dictionary {
158 | val dictionary = Dictionary() // from Godot type Dictionary
159 | dictionary["billing_cycle_count"] = phase.billingCycleCount
160 | dictionary["billing_period"] = phase.billingPeriod
161 | dictionary["formatted_price"] = phase.formattedPrice
162 | dictionary["price_amount_micros"] = phase.priceAmountMicros
163 | dictionary["price_currency_code"] = phase.priceCurrencyCode
164 | dictionary["recurrence_mode"] = phase.recurrenceMode
165 | return dictionary
166 | }
167 |
168 |
169 | // https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.InstallmentPlanDetails
170 | private fun convertInstallementPlanDetailsToArray(planDetails: ProductDetails.InstallmentPlanDetails?): Dictionary {
171 | val dictionary = Dictionary() // from Godot type Dictionary
172 | if (planDetails != null) {
173 | dictionary["installment_plan_commitment_payments_count"] = planDetails.installmentPlanCommitmentPaymentsCount
174 | dictionary["subsequent_installment_plan_commitment_payments_count"] = planDetails.subsequentInstallmentPlanCommitmentPaymentsCount
175 | }
176 | return dictionary
177 | }
178 |
179 | }
180 |
181 |
182 |
--------------------------------------------------------------------------------
/AndroidIAPP/src/test/java/one/allme/plugin/androidiapp/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package one.allme.plugin.androidiapp
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Max Parkhomenko
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 | # AndroidIAPP Godot Plugin
2 |
3 | AndroidIAPP is a [plugin]() for the Godot game engine. It provides an interface to work with Google Play Billing Library version 7. The plugin supports all public functions of the library, passes all error codes, and can work with different subscription plans.
4 |
5 | A simple game to demonstrate the work of purchases and subscriptions with different tariff plans: [Circle Сatcher 2](https://play.google.com/store/apps/details?id=org.godotengine.circlecatcher)
6 |
7 | ## Features
8 |
9 | - Connect to Google Play Billing.
10 | - Query purchases.
11 | - Query product details.
12 | - Make purchases and subscriptions.
13 | - Update purchases.
14 | - Consume and acknowledge purchases.
15 |
16 | ## Installation
17 |
18 | - Install the plugin using Godot [Asset Library](https://godotengine.org/asset-library/asset/3068).
19 |
20 | or
21 |
22 | - Download the plugin from [GitHub](https://github.com/code-with-max/godot-google-play-iapp/releases).
23 | - And place the unpacked plugin folder in the `res://addons/` directory of the project.
24 |
25 | > [!NOTE]
26 | > Dont forget to enable the plugin in the project settings.
27 |
28 | ## SIMPLE DEBUG
29 |
30 | - Make sure this plugin is activate in Project > Project Settings > Plugins
31 | - After you install the plugins, Check the AndroidIAPP.gd, make sure the path of .aar file is right
32 |
33 | `if debug:
34 | return PackedStringArray(["AndroidIAPP-debug.aar"])
35 | else:
36 | return PackedStringArray(["AndroidIAPP-release.aar"])`
37 |
38 | - this is just a billing helper file, you need something like this (https://gist.github.com/nitish800/60a1f3b6e746805b67a68395ca8f4ca6) and actually run this as autoload
39 |
40 | - Don't forgot to add this permission,
41 | com.android.vending.BILLING
42 | com.google.android.gms.permission.AD_ID
43 |
44 | You can add in Project > Export > Permissions > Custom Permissions
45 |
46 | ## Examples
47 |
48 | - [Example](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/billing_example.gd) of a script for working with a plugin
49 | - Another [Example](https://gist.github.com/code-with-max/56881cbb3796a19a68d8eabd819d6ff7)
50 |
51 | ## Before start
52 |
53 | - Google Play Billing uses these three types of purchases:
54 | - Products that will be consumed ("inapp").
55 | - Products that will be acknowledged ("inapp").
56 | - Subscriptions that will be purchased and consumed ("subs").
57 |
58 | And their IDs should be passed to the function as a list of String elements. Even if there is only one ID, it still needs to be wrapped in a list.
59 |
60 | - All public methods and values returned by Google Play Billing are presented as a typed Godot dictionary. All dictionary keys represent the names of public methods written in snake_case style.
61 | - getProductId -> `product_id`
62 | - getSubscriptionOfferDetails -> `subscription_offer_details`
63 |
64 | See the variant of response [here](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/details_inapp.json)
65 |
66 | - The plugin also includes all standard [BillingResponseCode](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode) messages as a key in the dictionary called `response_code`. Additionally, it adds a `debug_message` key if the code indicates an error.
67 |
68 | ## Signals Descriptions (Event listeners)
69 |
70 | ### Test signal
71 |
72 | *Returns a String value.*
73 |
74 | `helloResponse`: Emitted when a response to a hello message is received.
75 |
76 | ### Information signals
77 |
78 | *Does not return anything.*
79 |
80 | `startConnection`: Emitted when the connection to Google Play Billing starts.
81 |
82 | `connected`: Emitted when successfully connected to Google Play Billing.
83 |
84 | `disconnected`: Emitted when disconnected from Google Play Billing.
85 |
86 | ### Billing signals
87 |
88 | *Returns a Dictionary of Godot type.*
89 |
90 | `query_purchases`: Emitted when a query for purchases is successful.
91 | Returns a dictionary with purchases or subscriptions.
92 |
93 | `query_purchases_error`: Emitted when there is an error querying purchases.
94 | Returns a dictionary with error codes and debug message.
95 |
96 | `query_product_details`: Emitted when a query for product details is successful.
97 | Returns a dictionary with product or subscription details.
98 |
99 | `query_product_details_error`: Emitted when there is an error querying product details.
100 | Returns a dictionary with error codes and debug message.
101 |
102 | `purchase_error`: Emitted when there is an error during the purchase process.
103 | Returns a dictionary with error codes and debug message.
104 |
105 | `purchase_updated`: Emitted when the purchase information is updated.
106 | Returns a dictionary with purchases or subscriptions.
107 |
108 | `purchase_cancelled`: Emitted when a purchase is cancelled.
109 | Returns a dictionary with error codes and debug message.
110 |
111 | `purchase_update_error`: Emitted when there is an error updating the purchase information.
112 | Returns a dictionary with error codes and debug message.
113 |
114 | `purchase_consumed`: Emitted when a purchase is successfully consumed.
115 | Returns a dictionary with confirmation message.
116 |
117 | `purchase_consumed_error`: Emitted when there is an error consuming the purchase.
118 | Returns a dictionary with error codes and debug message.
119 |
120 | `purchase_acknowledged`: Emitted when a purchase is successfully acknowledged.
121 | Returns a dictionary with confirmation message.
122 |
123 | `purchase_acknowledged_error`: Emitted when there is an error acknowledging the purchase.
124 | Returns a dictionary with error codes and debug message.
125 |
126 | ## Functions
127 |
128 | `startConnection()`: Starts the connection to Google Play Billing, emit signals:
129 |
130 | - `startConnection` signal when connection is started.
131 | - `connected` signal if connection is successful.
132 |
133 | ---
134 | `isReady()`: Checks if the connection to Google Play Billing is ready and returns a boolean value.
135 |
136 | ---
137 | `sayHello()` : Sends a hello message from the plugin.
138 | *For testing purposes, not recommended in production.*
139 |
140 | - Emit `helloResponse` signal
141 | - Sending Log.v message to the console
142 | - Display a system toast.
143 |
144 | ---
145 | `queryPurchases(productType: String)`
146 | productType: **"inapp"** for products or **"subs"** for subscriptions.
147 | Handling purchases made [outside your app](https://developer.android.com/google/play/billing/integrate#ooap).
148 | > [!NOTE]
149 | > I recommend calling it every time you establish a connection with the billing service.
150 |
151 | Emit signals:
152 |
153 | - `query_purchases`: if a query for purchases is successful.
154 | - `query_purchases_error`: if there is an error querying purchases.
155 |
156 | ---
157 | `queryProductDetails(productId: List, productType: String)`: This function queries product of subscriptions details from Google Play Billing.
158 | `productId`: ID of the product or subscription wrapped in a list.
159 | `productType`: **"inapp"** for products or **"subs"** for subscriptions.
160 |
161 | > [!NOTE]
162 | > You must pass the product type as a parameter. If you pass the wrong product type with the product IDs, like using subscription IDs with "inapp", it won't work and the function will return an error.
163 |
164 | Emit signals:
165 |
166 | - `query_product_details`: If a query for product details is successful.
167 | - `query_product_details_error`: If error :)
168 |
169 | See an example of [product](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/details_inapp.json) details answer or [subscription](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/details_subscription.json).
170 |
171 | ---
172 | > [!NOTE]
173 | > This is where the biggest difference from the official plugin begins.
174 | > If you have never used the old plugin before, you don't need to worry.
175 | > But if you are planning to switch to this version, you should know that I have implemented two separate functions for buying products and subscribing to plans.
176 |
177 | ---
178 | `purchase(product_id: List, is_personalized: bool)`: purchase a product from Google Play Billing.
179 |
180 | - `product_id`: ID of the product wrapped in a list.
181 | - `is_personalized`: This is to ensure compliance with the [EU directive](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02011L0083-20220528), you can clarify this [here](https://developer.android.com/google/play/billing/integrate#personalized-price), but if you don't understand why, just set it to `false`.
182 |
183 | Emit signals:
184 |
185 | - `purchase_updated`: Emitted when the purchase information is updated. The purchase process was successful. [Example of response](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/purchase_updated_inapp.json)
186 | `query_product_details_error`: If an error occurred while receiving information about the product being purchased.
187 | - `purchase_error`: If there is an error during the purchase process.
188 | - `purchase_cancelled`: If a purchase is cancelled by the user.
189 | - `purchase_update_error`: If there is an error updating the purchase information.
190 |
191 | > [!IMPORTANT]
192 | > Do not forget **consume or acknowledge** the purchase.
193 |
194 | ---
195 | `subscribe(subscription_id: List, base_plan_id: List, is_personalized: bool)`: subscribe to a subscription plan from Google Play Billing.
196 |
197 | - `subscription_id`: ID of the subscription wrapped in a list.
198 | - `base_plan_id`: ID of the base subscription plan wrapped in a list.
199 | - `is_personalized`: This is to ensure compliance with the [EU directive](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:02011L0083-20220528), you can clarify this [here](https://developer.android.com/google/play/billing/integrate#personalized-price), but if you don't understand why, just set it to `false`.
200 |
201 | Emit signals:
202 |
203 | - `purchase_updated`: Emitted when the purchase information is updated. The purchase process was successful.
204 | - `query_product_details_error`: If an error occurred while receiving information about the subscription being purchased.
205 | - `purchase_error`: If there is an error during the purchase process.
206 | - `purchase_cancelled`: If a purchase is cancelled by the user.
207 | - `purchase_update_error`: If there is an error updating the purchase information.
208 |
209 | > [!IMPORTANT]
210 | > Do not forget **acknowledge** the subscription.
211 |
212 | ---
213 | `consumePurchase(purchase["purchase_token"]`: consume a purchase from Google Play Billing.
214 | `purchase["purchase_token"]`: Purchase token from purchase updated [response](https://github.com/code-with-max/godot-google-play-iapp/blob/master/examples/purchase_updated_inapp.json).
215 |
216 | Emit signals:
217 |
218 | - `purchase_consumed`: If a purchase is successfully consumed.
219 | - `purchase_consumed_error`: If there is an error consuming the purchase.
220 |
221 | ---
222 | `acknowledgePurchase(purchase["purchase_token"])`: acknowledge a purchase from Google Play Billing.
223 | `purchase["purchase_token"]`: Purchase token from purchase updated response.
224 |
225 | Emit signals:
226 |
227 | - `purchase_acknowledged`: If a purchase is successfully acknowledged.
228 | - `purchase_acknowledged_error`: If there is an error acknowledging the purchase.
229 |
230 | ---
231 |
232 | ## Step-by-step set up guide
233 |
234 | ### Connecting to the Google Play Billing Library
235 |
236 | ### Requesting a list of products and subscriptions
237 |
238 | ### Handling purchases and subscriptions
239 |
240 | ### Confirming and consuming purchases
241 |
242 | ### Handling errors and purchase states
243 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 | plugins {
3 | alias(libs.plugins.android.application) apply false
4 | alias(libs.plugins.jetbrains.kotlin.android) apply false
5 | alias(libs.plugins.android.library) apply false
6 | }
--------------------------------------------------------------------------------
/examples/billing_example.gd:
--------------------------------------------------------------------------------
1 | # AndroidIAPP is a plugin for the Godot game engine.
2 | # It provides an interface to work with Google Play Billing Library version 7.
3 | # The plugin supports all public functions of the library, passes all error codes, and can work with different subscription plans.
4 | # https://developer.android.com/google/play/billing
5 | #
6 | # You can use this plugin with any node in Godot.
7 | # But, I recommend adding this script as a singleton (autoload).
8 | # This makes it easier to access and use its functions from anywhere in your project.
9 | #
10 | # An example of working with a plugin:
11 |
12 |
13 | extends Node
14 |
15 | signal product_details_received(product_id: String, price: String)
16 | signal purchase_successful(product_id: String)
17 | signal purchase_failed(product_id: String, error: Dictionary)
18 |
19 |
20 | # https://developer.android.com/reference/com/android/billingclient/api/Purchase.PurchaseState
21 | enum purchaseState {
22 | UNSPECIFIED_STATE = 0,
23 | PURCHASED = 1,
24 | PENDING = 2,
25 | }
26 |
27 |
28 | # https://developer.android.com/reference/com/android/billingclient/api/BillingClient.BillingResponseCode
29 | enum billingResponseCode {
30 | SERVICE_TIMEOUT = -3,
31 | FEATURE_NOT_SUPPORTED = -2,
32 | SERVICE_DISCONNECTED = -1,
33 | OK = 0,
34 | USER_CANCELED = 1,
35 | SERVICE_UNAVAILABLE = 2,
36 | BILLING_UNAVAILABLE = 3,
37 | ITEM_UNAVAILABLE = 4,
38 | DEVELOPER_ERROR = 5,
39 | ERROR = 6,
40 | ITEM_ALREADY_OWNED = 7,
41 | ITEM_NOT_OWNED = 8,
42 | NETWORK_ERROR = 12
43 | }
44 |
45 |
46 | const ITEM_CONSUMATED: Array = [
47 | "additional_life_v1",
48 | ]
49 |
50 | const ITEM_ACKNOWLEDGED: Array = [
51 | "red_skin_v1",
52 | "blue_skin_v1",
53 | "yellow_skin_v1",
54 | ]
55 |
56 | const SUBSCRIPTIONS: Array = [
57 | "remove_ads_sub_01",
58 | "test_iapp_v7",
59 | ]
60 |
61 |
62 | var billing = null
63 |
64 |
65 | # Called when the node enters the scene tree for the first time.
66 | func _ready() -> void:
67 | await get_tree().create_timer(1).timeout
68 | run_iapp_billing()
69 |
70 |
71 | func run_iapp_billing():
72 | if Engine.has_singleton("AndroidIAPP"):
73 | # Get the singleton instance of AndroidIAPP
74 | billing = Engine.get_singleton("AndroidIAPP")
75 | print("AndroidIAPP singleton loaded")
76 |
77 | # Connection information
78 |
79 | # Handle the response from the helloResponse signal
80 | billing.helloResponse.connect(_on_hello_response)
81 | # Handle the startConnection signal
82 | billing.startConnection.connect(_on_start_connection)
83 | # Handle the connected signal
84 | billing.connected.connect(_on_connected)
85 | # Handle the disconnected signal
86 | billing.disconnected.connect(_on_disconnected)
87 |
88 | # Querying purchases
89 |
90 | # Handle the response from the query_purchases signal
91 | billing.query_purchases.connect(_on_query_purchases)
92 | # Handle the query_purchases_error signal
93 | billing.query_purchases_error.connect(_on_query_purchases_error)
94 |
95 | # Querying products details
96 |
97 | # Handle the response from the query_product_details signal
98 | billing.query_product_details.connect(query_product_details)
99 | # Handle the query_product_details_error signal
100 | billing.query_product_details_error.connect(_on_query_product_details_error)
101 |
102 | # Purchase processing
103 |
104 | # Handle the purchase signal
105 | billing.purchase.connect(_on_purchase)
106 | # Handle the purchase_error signal
107 | billing.purchase_error.connect(_on_purchase_error)
108 |
109 | # Purchase updating
110 |
111 | # Handle the purchase_updated signal
112 | billing.purchase_updated.connect(_on_purchase_updated)
113 | # Handle the purchase_cancelled signal
114 | billing.purchase_cancelled.connect(_on_purchase_cancelled)
115 | # Handle the purchase_update_error signal
116 | billing.purchase_update_error.connect(_on_purchase_update_error)
117 |
118 | # Purchase consuming
119 |
120 | # Handle the purchase_consumed signal
121 | billing.purchase_consumed.connect(_on_purchase_consumed)
122 | # Handle the purchase_consumed_error signal
123 | billing.purchase_consumed_error.connect(_on_purchase_consumed_error)
124 |
125 | # Purchase acknowledging
126 |
127 | # Handle the purchase_acknowledged signal
128 | billing.purchase_acknowledged.connect(_on_purchase_acknowledged)
129 | # Handle the purchase_acknowledged_error signal
130 | billing.purchase_acknowledged_error.connect(_on_purchase_acknowledged_error)
131 |
132 | # Connection
133 | billing.startConnection()
134 | else:
135 | printerr("AndroidIAPP singleton not found")
136 |
137 |
138 | func _on_start_connection() -> void:
139 | print("Billing: start connection")
140 |
141 |
142 | func _on_connected() -> void:
143 | print("Billing successfully connected")
144 | await get_tree().create_timer(0.4).timeout
145 | if billing.isReady():
146 | # billing.sayHello("Hello from Godot Google IAPP plugin :)")
147 | # Show products available to buy
148 | # https://developer.android.com/google/play/billing/integrate#show-products
149 | billing.queryProductDetails(ITEM_ACKNOWLEDGED, "inapp")
150 | billing.queryProductDetails(ITEM_CONSUMATED, "inapp")
151 | billing.queryProductDetails(SUBSCRIPTIONS, "subs")
152 | # Handling purchases made outside your app
153 | # https://developer.android.com/google/play/billing/integrate#ooap
154 | billing.queryPurchases("subs")
155 | billing.queryPurchases("inapp")
156 |
157 |
158 | func _on_disconnected() -> void:
159 | print("Billing disconnected")
160 |
161 |
162 | func _on_hello_response(response) -> void:
163 | print("Hello signal response: " + response)
164 |
165 |
166 | func query_product_details(response) -> void:
167 | for product in response["product_details_list"]:
168 | #var product = response["product_details_list"][i]
169 | #print(JSON.stringify(product["product_id"], " "))
170 | var product_id = product["product_id"]
171 | var price = product["one_time_purchase_offer_details"]["formatted_price"]
172 | product_details_received.emit(product_id, price)
173 | #
174 | # Handle avaible for purchase product details here
175 | #
176 |
177 | func _on_query_purchases(response) -> void:
178 | print("on_query_Purchases_response: ")
179 | for purchase in response["purchases_list"]:
180 | process_purchase(purchase)
181 |
182 |
183 | func _on_purchase_updated(response):
184 | for purchase in response["purchases_list"]:
185 | process_purchase(purchase)
186 |
187 |
188 | # Processing incoming purchase
189 | func process_purchase(purchase):
190 | for product in purchase["products"]:
191 | if (product in ITEM_ACKNOWLEDGED) or (product in SUBSCRIPTIONS):
192 | # Acknowledge the purchase
193 | if not purchase["is_acknowledged"]:
194 | print("Acknowledging: " + purchase["purchase_token"])
195 | billing.acknowledgePurchase(purchase["purchase_token"])
196 | #
197 | # Here, process the use of the product in your game.
198 | #
199 | else:
200 | print("Already acknowledged")
201 | elif product in ITEM_CONSUMATED:
202 | # Consume the purchase
203 | print("Consuming: " + purchase["purchase_token"])
204 | billing.consumePurchase(purchase["purchase_token"])
205 | #
206 | # Here, process the use of the product in your game.
207 | #
208 | else:
209 | print("Product not found: " + str(product))
210 |
211 |
212 | # Purchase
213 | func do_purchase(id: String, is_personalized: bool = false):
214 | billing.purchase([id], is_personalized)
215 |
216 | # Subscriptions
217 | func do_subsciption(subscription_id: String, base_plan_id: String , is_personalized: bool = false):
218 | billing.subscribe([subscription_id], [base_plan_id], is_personalized)
219 |
220 |
221 | func print_purchases(purchases):
222 | for purchase in purchases:
223 | print(JSON.stringify(purchase, " "))
224 |
225 |
226 | func _on_purchase(response) -> void:
227 | print("Purchase started:")
228 | print(JSON.stringify(response, " "))
229 |
230 |
231 | func _on_purchase_cancelled(response) -> void:
232 | print("Purchase_cancelled:")
233 | print(JSON.stringify(response, " "))
234 |
235 |
236 | func _on_purchase_consumed(response) -> void:
237 | print("Purchase_consumed:")
238 | print(JSON.stringify(response, " "))
239 |
240 |
241 | func _on_purchase_acknowledged(response) -> void:
242 | print("Purchase_acknowledged:")
243 | print(JSON.stringify(response, " "))
244 |
245 |
246 | func _on_purchase_update_error(error) -> void:
247 | print(JSON.stringify(error, " "))
248 |
249 |
250 | func _on_purchase_error(error) -> void:
251 | print(JSON.stringify(error, " "))
252 |
253 |
254 | func _on_purchase_consumed_error(error) -> void:
255 | print(JSON.stringify(error, " "))
256 |
257 |
258 | func _on_purchase_acknowledged_error(error) -> void:
259 | print(JSON.stringify(error, " "))
260 |
261 |
262 | func _on_query_purchases_error(error) -> void:
263 | print(JSON.stringify(error, " "))
264 |
265 |
266 | func _on_query_product_details_error(error) -> void:
267 | print(JSON.stringify(error, " "))
268 |
--------------------------------------------------------------------------------
/examples/details_inapp.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Beautiful sky-blue skin for circle catcher.",
3 | "hash_code": 1042382311,
4 | "name": "Blue skin",
5 | "one_time_purchase_offer_details": {
6 | "formatted_price": "1,29 CA$",
7 | "price_amount_micros": 1290000,
8 | "price_currency_code": "CAD"
9 | },
10 | "product_id": "blue_skin_v1",
11 | "product_type": "inapp",
12 | "title": "Blue skin (Circle catcher 2)",
13 | "to_string": "ProductDetails{jsonString='{\"productId\":\"blue_skin_v1\",\"type\":\"inapp\",\"title\":\"Blue skin (Circle catcher 2)\",\"name\":\"Blue skin\",\"description\":\"Beautiful sky-blue skin for circle catcher.\",\"localizedIn\":[\"en-US\"],\"skuDetailsToken\":\"AEuhp4Ik3jZtacec7suJ2k1UwRNw1TAURMDxhuBdJVAvUszGtiXhi5IouYzz023Wz_3n9L-VCkaEOvo=\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":1290000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"1,29 CA$\",\"offerIdToken\":\"AbNbjn6qd8NV87Re\\/Gu8BaDG15I1ttYTPGQklCYCEKswhA2AYrbJgEaLxvr8PzAhsQYGGMuXkxbh\\/8kb2wySL7bDGg==\"}}', parsedJson={\"productId\":\"blue_skin_v1\",\"type\":\"inapp\",\"title\":\"Blue skin (Circle catcher 2)\",\"name\":\"Blue skin\",\"description\":\"Beautiful sky-blue skin for circle catcher.\",\"localizedIn\":[\"en-US\"],\"skuDetailsToken\":\"AEuhp4Ik3jZtacec7suJ2k1UwRNw1TAURMDxhuBdJVAvUszGtiXhi5IouYzz023Wz_3n9L-VCkaEOvo=\",\"oneTimePurchaseOfferDetails\":{\"priceAmountMicros\":1290000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"1,29 CA$\",\"offerIdToken\":\"AbNbjn6qd8NV87Re\\/Gu8BaDG15I1ttYTPGQklCYCEKswhA2AYrbJgEaLxvr8PzAhsQYGGMuXkxbh\\/8kb2wySL7bDGg==\"}}, productId='blue_skin_v1', productType='inapp', title='Blue skin (Circle catcher 2)', productDetailsToken='AEuhp4Ik3jZtacec7suJ2k1UwRNw1TAURMDxhuBdJVAvUszGtiXhi5IouYzz023Wz_3n9L-VCkaEOvo=', subscriptionOfferDetails=null}"
14 | }
15 |
--------------------------------------------------------------------------------
/examples/details_subscription.json:
--------------------------------------------------------------------------------
1 | {
2 | "description": "Remove all ADs from app one one year.",
3 | "hash_code": -1341575204,
4 | "name": "Remove all ADs from app",
5 | "product_id": "remove_ads_sub_01",
6 | "product_type": "subs",
7 | "subscription_offer_details": [
8 | {
9 | "base_plan_id": "remove-ads-on-mounth",
10 | "installment_plan_details": {
11 |
12 | },
13 | "offer_id": null,
14 | "offer_tags": [
15 | "ads",
16 | "mounth"
17 | ],
18 | "offer_token": "AbNbjn6TaIQy+qPjOjdLgFDKeSMVXjCx1DDgdhYNjVblNZ0d2GRCLJ/FWAFnIsPUe8NER3mp64JbMnUXORLfCMKLmc046GWIIdZy9jc=",
19 | "pricing_phases": [
20 | {
21 | "billing_cycle_count": 0,
22 | "billing_period": "P1M",
23 | "formatted_price": "1,39 CA$",
24 | "price_amount_micros": 1390000,
25 | "price_currency_code": "CAD",
26 | "recurrence_mode": 1
27 | }
28 | ]
29 | },
30 | {
31 | "base_plan_id": "remove-ads-on-year",
32 | "installment_plan_details": {
33 |
34 | },
35 | "offer_id": null,
36 | "offer_tags": [
37 | "ads",
38 | "year"
39 | ],
40 | "offer_token": "AbNbjn4L6u7g6/hdXadrh8DN5gpEJ6Df9qwu9zvrwSSNpDZ/xwktiJ1668rLTaBQcC/cZsh6964WTWIjXPgIeoy7BACAMtaSi5nL",
41 | "pricing_phases": [
42 | {
43 | "billing_cycle_count": 0,
44 | "billing_period": "P1Y",
45 | "formatted_price": "12,99 CA$",
46 | "price_amount_micros": 12990000,
47 | "price_currency_code": "CAD",
48 | "recurrence_mode": 1
49 | }
50 | ]
51 | }
52 | ],
53 | "title": "Remove all ADs from app (Circle catcher 2)",
54 | "to_string": "ProductDetails{jsonString='{\"productId\":\"remove_ads_sub_01\",\"type\":\"subs\",\"title\":\"Remove all ADs from app (Circle catcher 2)\",\"name\":\"Remove all ADs from app\",\"description\":\"Remove all ADs from app one one year.\",\"localizedIn\":[\"en-US\"],\"skuDetailsToken\":\"AEuhp4ILup4nhJvTjaewXqded-BbLooEPhm9MvUQWBuQ27NI1U6D1cN7E6Yi_E8pMzqc\",\"subscriptionOfferDetails\":[{\"offerIdToken\":\"AbNbjn6TaIQy+qPjOjdLgFDKeSMVXjCx1DDgdhYNjVblNZ0d2GRCLJ\\/FWAFnIsPUe8NER3mp64JbMnUXORLfCMKLmc046GWIIdZy9jc=\",\"basePlanId\":\"remove-ads-on-mounth\",\"pricingPhases\":[{\"priceAmountMicros\":1390000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"1,39 CA$\",\"billingPeriod\":\"P1M\",\"recurrenceMode\":1}],\"offerTags\":[\"ads\",\"mounth\"]},{\"offerIdToken\":\"AbNbjn4L6u7g6\\/hdXadrh8DN5gpEJ6Df9qwu9zvrwSSNpDZ\\/xwktiJ1668rLTaBQcC\\/cZsh6964WTWIjXPgIeoy7BACAMtaSi5nL\",\"basePlanId\":\"remove-ads-on-year\",\"pricingPhases\":[{\"priceAmountMicros\":12990000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"12,99 CA$\",\"billingPeriod\":\"P1Y\",\"recurrenceMode\":1}],\"offerTags\":[\"ads\",\"year\"]}]}', parsedJson={\"productId\":\"remove_ads_sub_01\",\"type\":\"subs\",\"title\":\"Remove all ADs from app (Circle catcher 2)\",\"name\":\"Remove all ADs from app\",\"description\":\"Remove all ADs from app one one year.\",\"localizedIn\":[\"en-US\"],\"skuDetailsToken\":\"AEuhp4ILup4nhJvTjaewXqded-BbLooEPhm9MvUQWBuQ27NI1U6D1cN7E6Yi_E8pMzqc\",\"subscriptionOfferDetails\":[{\"offerIdToken\":\"AbNbjn6TaIQy+qPjOjdLgFDKeSMVXjCx1DDgdhYNjVblNZ0d2GRCLJ\\/FWAFnIsPUe8NER3mp64JbMnUXORLfCMKLmc046GWIIdZy9jc=\",\"basePlanId\":\"remove-ads-on-mounth\",\"pricingPhases\":[{\"priceAmountMicros\":1390000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"1,39 CA$\",\"billingPeriod\":\"P1M\",\"recurrenceMode\":1}],\"offerTags\":[\"ads\",\"mounth\"]},{\"offerIdToken\":\"AbNbjn4L6u7g6\\/hdXadrh8DN5gpEJ6Df9qwu9zvrwSSNpDZ\\/xwktiJ1668rLTaBQcC\\/cZsh6964WTWIjXPgIeoy7BACAMtaSi5nL\",\"basePlanId\":\"remove-ads-on-year\",\"pricingPhases\":[{\"priceAmountMicros\":12990000,\"priceCurrencyCode\":\"CAD\",\"formattedPrice\":\"12,99 CA$\",\"billingPeriod\":\"P1Y\",\"recurrenceMode\":1}],\"offerTags\":[\"ads\",\"year\"]}]}, productId='remove_ads_sub_01', productType='subs', title='Remove all ADs from app (Circle catcher 2)', productDetailsToken='AEuhp4ILup4nhJvTjaewXqded-BbLooEPhm9MvUQWBuQ27NI1U6D1cN7E6Yi_E8pMzqc', subscriptionOfferDetails=[com.android.billingclient.api.ProductDetails$SubscriptionOfferDetails@e3d71fe, com.android.billingclient.api.ProductDetails$SubscriptionOfferDetails@1591d5f]}"
55 | }
56 |
--------------------------------------------------------------------------------
/examples/payment-method.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-with-max/godot-google-play-iapp/b40ecb140a7d22a8923d543d4c8a669b68bf5dbf/examples/payment-method.png
--------------------------------------------------------------------------------
/examples/purchase_updated_inapp.json:
--------------------------------------------------------------------------------
1 | {
2 | "account_identifiers": null,
3 | "developer_payload": "",
4 | "hash_code": 347836291,
5 | "is_acknowledged": false,
6 | "is_auto_renewing": false,
7 | "order_id": "GPA.3386-6160-2931-85348",
8 | "original_json": "{\"orderId\":\"GPA.3386-6160-2931-85348\",\"packageName\":\"org.godotengine.circlecatcher\",\"productId\":\"blue_skin_v1\",\"purchaseTime\":1718385548694,\"purchaseState\":0,\"purchaseToken\":\"hbecfldgjckddhaefmoomgdm.AO-J1OxmTkHVIaH0xGRLKk53AvFGuj2gwKmldZ6YLAdfKTigvqE306j3cW38a_H8zd-DTr5-ZB-rWSbqUZFRuDpT3rGQyiicFN4e8VNd6qv4NWjc7L1opSU\",\"quantity\":1,\"acknowledged\":false}",
9 | "package_name": "org.godotengine.circlecatcher",
10 | "pending_purchase_update": null,
11 | "products": [
12 | "blue_skin_v1"
13 | ],
14 | "purchase_state": 1,
15 | "purchase_time": 1718385548694,
16 | "purchase_token": "hbecfldgjckddhaefmoomgdm.AO-J1OxmTkHVIaH0xGRLKk53AvFGuj2gwKmldZ6YLAdfKTigvqE306j3cW38a_H8zd-DTr5-ZB-rWSbqUZFRuDpT3rGQyiicFN4e8VNd6qv4NWjc7L1opSU",
17 | "quantity": 1,
18 | "signature": "A0NkdhKPTSubDkUgu9HzVvJ33G0bZ18a/LX5NoK5WHXZmO3qznf8GESw/bda1A76CbX0PpiDaDIWGLK8b0huTmmdxSL+2wjw+LnaABc+hSf6KjD8Zmu/doMA6ScihP9Ilv9/t18S8JFpJdvUOUvKMs//EP0bxvmxJbAin+Y8QUdKuL7Bgj4NY75Vka4UWbqApyGdRYOkU5R4DZXV8zhaeRbbbevOmWd4AgLctJv+ZhVSeWyY9g5ONlPutVPPg3nwz4RBFL49yIarXGKvaZicM1weqJX5rtV/H/2fTSTUqp/vqigZEDYGI/MzLQw8M6GjI2qUSuFf8MMu+Pvl5zPW4A==",
19 | "to_string": "Purchase. Json: {\"orderId\":\"GPA.3386-6160-2931-85348\",\"packageName\":\"org.godotengine.circlecatcher\",\"productId\":\"blue_skin_v1\",\"purchaseTime\":1718385548694,\"purchaseState\":0,\"purchaseToken\":\"hbecfldgjckddhaefmoomgdm.AO-J1OxmTkHVIaH0xGRLKk53AvFGuj2gwKmldZ6YLAdfKTigvqE306j3cW38a_H8zd-DTr5-ZB-rWSbqUZFRuDpT3rGQyiicFN4e8VNd6qv4NWjc7L1opSU\",\"quantity\":1,\"acknowledged\":false}"
20 | }
21 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. For more details, visit
12 | # https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app's APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Kotlin code style for this project: "official" or "obsolete":
19 | kotlin.code.style=official
20 | # Enables namespacing of each library's R class so that its R class includes only the
21 | # resources declared in the library itself and none from the library's dependencies,
22 | # thereby reducing the size of the R class for that library
23 | android.nonTransitiveRClass=true
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.8.2"
3 | kotlin = "1.9.0"
4 | coreKtx = "1.15.0"
5 | junit = "4.13.2"
6 | junitVersion = "1.2.1"
7 | espressoCore = "3.6.1"
8 | appcompat = "1.7.0"
9 | material = "1.12.0"
10 | godot = "4.4.1.stable"
11 | billingKtx = "7.1.1"
12 | billing = "7.1.1"
13 | godotVersion = "4.4.1.stable"
14 |
15 | [libraries]
16 | androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version = "1.16.0" }
17 | junit = { group = "junit", name = "junit", version.ref = "junit" }
18 | androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
19 | androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
20 | androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version = "1.7.0" }
21 | material = { group = "com.google.android.material", name = "material", version = "1.12.0" }
22 | godot = { group = "org.godotengine", name = "godot", version.ref = "godot" }
23 | billing-ktx = { group = "com.android.billingclient", name = "billing-ktx", version = "7.1.1" }
24 | billing = { group = "com.android.billingclient", name = "billing", version.ref = "billingKtx" }
25 | godotengine-godot = { group = "org.godotengine", name = "godot", version = "4.4.1.stable" }
26 |
27 | [plugins]
28 | android-application = { id = "com.android.application", version.ref = "agp" }
29 | jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
30 | android-library = { id = "com.android.library", version.ref = "agp" }
31 |
32 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/code-with-max/godot-google-play-iapp/b40ecb140a7d22a8923d543d4c8a669b68bf5dbf/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Sun Feb 16 18:13:34 CST 2025
2 | distributionBase=GRADLE_USER_HOME
3 | distributionPath=wrapper/dists
4 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip
5 | zipStoreBase=GRADLE_USER_HOME
6 | zipStorePath=wrapper/dists
7 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google {
4 | content {
5 | includeGroupByRegex("com\\.android.*")
6 | includeGroupByRegex("com\\.google.*")
7 | includeGroupByRegex("androidx.*")
8 | }
9 | }
10 | mavenCentral()
11 | gradlePluginPortal()
12 | }
13 | }
14 | dependencyResolutionManagement {
15 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
16 | repositories {
17 | google()
18 | mavenCentral()
19 | }
20 | }
21 |
22 | rootProject.name = "GoogleIAPP"
23 | include(":AndroidIAPP")
24 |
--------------------------------------------------------------------------------