├── .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 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | --------------------------------------------------------------------------------