Compare commits

...

35 Commits

Author SHA1 Message Date
Björn Ritzl
88028fd29f Update index.md 2025-06-04 07:54:03 +02:00
Björn Ritzl
c0e35039f4
Merge pull request #66 from defold/dev-update-to-android-billing-7-0-0
Update to Google Play Billing 7 0 0
2025-02-11 22:23:42 +01:00
Björn Ritzl
2e17b2f413 Merge branch 'master' into dev-update-to-android-billing-7-0-0 2025-02-11 13:58:34 +01:00
Björn Ritzl
f398677d7b
Merge pull request #68 from defold/dev-update-to-android-billing-6-0-1
Update to android billing 6.2.1
2024-07-04 15:18:13 +02:00
Björn Ritzl
d1c4e88562 Update build.gradle 2024-07-04 15:04:32 +02:00
Björn Ritzl
6f06d7f08f Updated to 6.0.1 2024-07-04 14:52:58 +02:00
Björn Ritzl
0852e42977 Update .gitignore 2024-07-04 14:36:12 +02:00
Björn Ritzl
33174f25ea Merge branch 'master' into dev-update-to-android-billing-7-0-0 2024-07-03 19:27:05 +02:00
Björn Ritzl
ba0e1b645a
Merge pull request #65 from defold/dev-update-to-android-billing-6-0-0
Updated to Play Billing 6.0.0
2024-07-03 19:26:34 +02:00
Björn Ritzl
801179288e Include the offer token when buying 2024-07-03 19:25:36 +02:00
Björn Ritzl
1c27130eef Updated to Billing 7.0.0 2024-07-03 19:06:50 +02:00
Björn Ritzl
195ef400b5 Play Billing 6.0.0 2024-07-02 22:33:09 +02:00
Alexey Gulev
68ef7f4615
Merge pull request #63 from ekharkunov/emscripten-update
Update js code according to new Emscripten version (3.1.55).
2024-04-10 11:08:22 +02:00
Kharkunov Eugene
1a34582603 Update js code according to new Emscripten version (3.1.55).
Update assets to latest Defold version.
Remove armv7-ios from manifest as unsupported platform.
2024-04-10 11:27:41 +03:00
Björn Ritzl
09f5060d44 Update index.md 2023-10-03 14:40:19 +02:00
Björn Ritzl
649a8a1ebf
Merge pull request #58 from defold/Issue-47-index-out-of-bounds-exception
Added product and purchase null checks
2023-02-07 15:53:39 +01:00
Björn Ritzl
5c09447e37
Merge pull request #59 from defold/Issue-56-app-crash-on-slow-purchase
Check that the purchase listener is not null
2023-02-07 15:51:25 +01:00
Björn Ritzl
ad06de7b9c Check purchase listener 2023-02-06 23:11:24 +01:00
Björn Ritzl
d74c97d5c7 Added null checks 2023-02-06 15:56:03 +01:00
Björn Ritzl
f97a7ee6b6
Merge pull request #55 from defold/fix-json-migration-issue
Push nil as last argument if there is no error
2022-11-21 10:30:12 +01:00
Björn Ritzl
5f3f43fb2e Push nil as last argument if there is no error 2022-11-21 10:21:12 +01:00
Mathias Westerdahl
caff4397d8
Merge pull request #54 from defold/lua-to-json
Updated to use dmScript::LuaToJson()
2022-11-02 15:00:12 +01:00
JCash
501af9c90d Updated to use dmScript::LuaToJson() 2022-11-02 14:36:36 +01:00
Björn Ritzl
961a43f732
Update index.md 2022-09-27 14:46:24 +02:00
Björn Ritzl
47f03108ab Merge branch 'master' of https://github.com/defold/extension-iap 2022-09-23 16:21:36 +02:00
Björn Ritzl
c0e1a9aef1 Fixed YAML format errors 2022-09-23 16:21:27 +02:00
Alexey Gulev
f4294a9eb5
Merge pull request #53 from defold/AGulev-patch-1
Update index.md
2022-09-12 16:26:20 +02:00
Alexey Gulev
8799860198
Update index.md 2022-09-12 16:26:07 +02:00
Björn Ritzl
40648f4773
Merge pull request #52 from defold/Issue-51-update-billing-library-to-version-5
Updated the Android billing library to version 5
2022-09-08 10:32:27 +02:00
Björn Ritzl
3d3433e07f Update iap.script_api 2022-09-08 10:32:04 +02:00
Björn Ritzl
52493d8c83 Improved API docs. Recurrence mode 2022-09-08 10:06:10 +02:00
Björn Ritzl
c09aa64ab5 Added title and description 2022-09-08 09:51:39 +02:00
Björn Ritzl
d0ca1e05fd Update iap.script_api 2022-09-08 09:51:28 +02:00
Björn Ritzl
05e9001404 Fixed issues with subscriptions 2022-09-07 11:58:19 +02:00
Björn Ritzl
a26bb45791 Initial update 2022-09-06 23:59:27 +02:00
16 changed files with 416 additions and 201 deletions

2
.gitignore vendored
View File

@ -9,3 +9,5 @@ Thumbs.db
.cproject .cproject
builtins builtins
_site _site
manifest.private.der
manifest.public.der

View File

@ -1,6 +1,6 @@
--- ---
title: Defold In-app purchase extension API documentation title: Defold In-app purchase extension API documentation
brief: This manual covers how to setup and use Google Play Game Services in Defold. brief: This manual covers how to setup and use In-App Purchases in Defold.
--- ---
# Defold In-app purchase extension API documentation # Defold In-app purchase extension API documentation
@ -8,7 +8,7 @@ brief: This manual covers how to setup and use Google Play Game Services in Defo
This extension provides a unified, simple to use interface to several different stores for in-app purchase: This extension provides a unified, simple to use interface to several different stores for in-app purchase:
* Apples iOS Appstore - StoreKit * Apples iOS Appstore - StoreKit
* Google Play Billing 3.0 * Google Play Billing 5.0
* Amazon 'in-app billing' 2.0.61 * Amazon 'in-app billing' 2.0.61
* Facebook Canvas 'game payments' * Facebook Canvas 'game payments'
@ -31,7 +31,7 @@ Detailed documentation from Apple, Google, Amazon and Facebook can be found here
## Installation ## Installation
To use this library in your Defold project, add the following URL to your `game.project` dependencies: To use this library in your Defold project, add the following URL to your `game.project` dependencies:
https://github.com/defold/extension-iap/archive/master.zip [https://github.com/defold/extension-iap/archive/master.zip](https://github.com/defold/extension-iap/archive/master.zip)
We recommend using a link to a zip file of a [specific release](https://github.com/defold/extension-iap/releases). We recommend using a link to a zip file of a [specific release](https://github.com/defold/extension-iap/releases).
@ -234,6 +234,3 @@ On iOS, the "price_string" field contains '~' characters
## Source code ## Source code
The source code is available on [GitHub](https://github.com/defold/extension-iap) The source code is available on [GitHub](https://github.com/defold/extension-iap)
## API reference

View File

@ -21,8 +21,12 @@
members: members:
- name: request_id - name: request_id
type: string type: string
desc: Facebook only. [icon:facebook] Optional custom unique request id to desc: Facebook only [icon:facebook]. Optional custom unique request id to
set for this transaction. The id becomes attached to the payment within the Graph API. set for this transaction. The id becomes attached to the payment within the Graph API.
- name: token
type: string
desc: Google Play only [icon:googleplay]. Which subscription offer to use when buying a subscription. The token can be retrieved from
the subscriptions table returned when calling iap.list()
examples: examples:
- desc: |- - desc: |-
@ -128,15 +132,56 @@
- name: price - name: price
type: number type: number
desc: The price of the product. desc: The price of the product.
For Google Play [icon:googleplay] this is used only for in-app products
- name: price_string - name: price_string
type: string type: string
desc: The price of the product, as a formatted string (amount and currency symbol). desc: The price of the product, as a formatted string (amount and currency symbol).
For Google Play [icon:googleplay] this is used only for in-app products
- name: currency_code - name: currency_code
type: string type: string
desc: The currency code. On Google Play, this reflects the merchant's locale, instead of the user's. desc: The currency code.
[icon:ios] [icon:googleplay] [icon:facebook] For Google Play [icon:googleplay] this is the merchant's locale, instead of the user's.
For Google Play [icon:googleplay] this is used only for in-app products
- name: subscriptions
type: table
desc: Only available for Google Play [icon:googleplay]. List of subscription offers.
Each offer contains a token and a list of price and billing options.
See https://developer.android.com/reference/com/android/billingclient/api/ProductDetails.PricingPhase
members:
- name: token
type: string
desc: The token associated with the pricing phases for the subscription.
- name: pricing
type: table
desc: The pricing phases for the subscription.
members:
- name: price_string
type: string
desc: Formatted price for the payment cycle, including currency sign.
- name: price
type: number
desc: Price of the payment cycle in micro-units.
- name: currency_code
type: string
desc: ISO 4217 currency code
- name: billing_period
type: string
desc: Billing period of the payment cycle, specified in ISO 8601 format
- name: billing_cycle_count
type: number
desc: Number of cycles for which the billing period is applied.
- name: recurrence_mode
type: string
desc: FINITE, INFINITE or NONE
- name: error - name: error
type: table type: table

View File

@ -1,10 +1,6 @@
name: IAPExt name: IAPExt
platforms: platforms:
armv7-ios:
context:
weakFrameworks: ['StoreKit', 'UIKit', 'Foundation']
arm64-ios: arm64-ios:
context: context:
weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] weakFrameworks: ['StoreKit', 'UIKit', 'Foundation']

View File

@ -71,7 +71,7 @@ var LibraryFacebookIAP = {
if(url_index == product_count-1) { if(url_index == product_count-1) {
var productsJSON = JSON.stringify(products); var productsJSON = JSON.stringify(products);
var res_buf = allocate(intArrayFromString(productsJSON), 'i8', ALLOC_STACK); var res_buf = stringToUTF8OnStack(productsJSON);
{{{ makeDynCall('vii', 'callback')}}}(lua_callback, res_buf); {{{ makeDynCall('vii', 'callback')}}}(lua_callback, res_buf);
} else { } else {
var xmlhttp = new XMLHttpRequest(); var xmlhttp = new XMLHttpRequest();
@ -143,7 +143,7 @@ var LibraryFacebookIAP = {
} }
var productsJSON = JSON.stringify(result) var productsJSON = JSON.stringify(result)
var res_buf = allocate(intArrayFromString(productsJSON), 'i8', ALLOC_STACK); var res_buf = stringToUTF8OnStack(productsJSON);
{{{ makeDynCall('viii', 'callback')}}}(lua_callback, res_buf, 0); {{{ makeDynCall('viii', 'callback')}}}(lua_callback, res_buf, 0);
} else { } else {
@ -166,4 +166,4 @@ var LibraryFacebookIAP = {
} }
autoAddDeps(LibraryFacebookIAP, '$FBinner'); autoAddDeps(LibraryFacebookIAP, '$FBinner');
mergeInto(LibraryManager.library, LibraryFacebookIAP); addToLibrary(LibraryFacebookIAP);

View File

@ -1,3 +1,7 @@
dependencies { repositories {
implementation 'com.android.billingclient:billing:3.0.0' mavenCentral()
}
dependencies {
implementation 'com.android.billingclient:billing:7.0.0'
} }

View File

@ -31,6 +31,7 @@ enum BillingResponse
BILLING_RESPONSE_RESULT_ERROR = 6, BILLING_RESPONSE_RESULT_ERROR = 6,
BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7, BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7,
BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8, BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8,
BILLING_RESPONSE_RESULT_NETWORK_ERROR = 9,
}; };
enum ProviderId enum ProviderId

View File

@ -40,6 +40,8 @@ static IAP g_IAP;
static int IAP_ProcessPendingTransactions(lua_State* L) static int IAP_ProcessPendingTransactions(lua_State* L)
{ {
DM_LUA_STACK_CHECK(L, 0);
dmAndroid::ThreadAttacher threadAttacher; dmAndroid::ThreadAttacher threadAttacher;
JNIEnv* env = threadAttacher.GetEnv(); JNIEnv* env = threadAttacher.GetEnv();
env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI); env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI);
@ -49,11 +51,11 @@ static int IAP_ProcessPendingTransactions(lua_State* L)
static int IAP_List(lua_State* L) static int IAP_List(lua_State* L)
{ {
int top = lua_gettop(L); DM_LUA_STACK_CHECK(L, 0);
char* buf = IAP_List_CreateBuffer(L); char* buf = IAP_List_CreateBuffer(L);
if( buf == 0 ) if( buf == 0 )
{ {
assert(top == lua_gettop(L));
return 0; return 0;
} }
@ -68,36 +70,46 @@ static int IAP_List(lua_State* L)
env->DeleteLocalRef(products); env->DeleteLocalRef(products);
free(buf); free(buf);
assert(top == lua_gettop(L));
return 0; return 0;
} }
static int IAP_Buy(lua_State* L) static int IAP_Buy(lua_State* L)
{ {
int top = lua_gettop(L); DM_LUA_STACK_CHECK(L, 0);
int top = lua_gettop(L);
const char* id = luaL_checkstring(L, 1); const char* id = luaL_checkstring(L, 1);
const char* token = "";
if (top >= 2 && lua_istable(L, 2)) {
luaL_checktype(L, 2, LUA_TTABLE);
lua_pushvalue(L, 2);
lua_getfield(L, -1, "token");
token = lua_isnil(L, -1) ? "" : luaL_checkstring(L, -1);
lua_pop(L, 2);
}
dmAndroid::ThreadAttacher threadAttacher; dmAndroid::ThreadAttacher threadAttacher;
JNIEnv* env = threadAttacher.GetEnv(); JNIEnv* env = threadAttacher.GetEnv();
jstring ids = env->NewStringUTF(id); jstring ids = env->NewStringUTF(id);
env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Buy, ids, g_IAP.m_IAPJNI); jstring tokens = env->NewStringUTF(token);
env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Buy, ids, tokens, g_IAP.m_IAPJNI);
env->DeleteLocalRef(ids); env->DeleteLocalRef(ids);
env->DeleteLocalRef(tokens);
assert(top == lua_gettop(L));
return 0; return 0;
} }
static int IAP_Finish(lua_State* L) static int IAP_Finish(lua_State* L)
{ {
DM_LUA_STACK_CHECK(L, 0);
if(g_IAP.m_autoFinishTransactions) if(g_IAP.m_autoFinishTransactions)
{ {
dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored."); dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored.");
return 0; return 0;
} }
int top = lua_gettop(L);
luaL_checktype(L, 1, LUA_TTABLE); luaL_checktype(L, 1, LUA_TTABLE);
lua_getfield(L, -1, "state"); lua_getfield(L, -1, "state");
@ -107,7 +119,6 @@ static int IAP_Finish(lua_State* L)
{ {
dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED)."); dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED).");
lua_pop(L, 1); lua_pop(L, 1);
assert(top == lua_gettop(L));
return 0; return 0;
} }
} }
@ -130,13 +141,12 @@ static int IAP_Finish(lua_State* L)
env->DeleteLocalRef(receiptUTF); env->DeleteLocalRef(receiptUTF);
} }
assert(top == lua_gettop(L));
return 0; return 0;
} }
static int IAP_Acknowledge(lua_State* L) static int IAP_Acknowledge(lua_State* L)
{ {
int top = lua_gettop(L); DM_LUA_STACK_CHECK(L, 0);
luaL_checktype(L, 1, LUA_TTABLE); luaL_checktype(L, 1, LUA_TTABLE);
@ -147,7 +157,6 @@ static int IAP_Acknowledge(lua_State* L)
{ {
dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED)."); dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED).");
lua_pop(L, 1); lua_pop(L, 1);
assert(top == lua_gettop(L));
return 0; return 0;
} }
} }
@ -170,7 +179,6 @@ static int IAP_Acknowledge(lua_State* L)
env->DeleteLocalRef(receiptUTF); env->DeleteLocalRef(receiptUTF);
} }
assert(top == lua_gettop(L));
return 0; return 0;
} }
@ -178,20 +186,20 @@ static int IAP_Restore(lua_State* L)
{ {
// TODO: Missing callback here for completion/error // TODO: Missing callback here for completion/error
// See iap_ios.mm // See iap_ios.mm
DM_LUA_STACK_CHECK(L, 1);
int top = lua_gettop(L);
dmAndroid::ThreadAttacher threadAttacher; dmAndroid::ThreadAttacher threadAttacher;
JNIEnv* env = threadAttacher.GetEnv(); JNIEnv* env = threadAttacher.GetEnv();
env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Restore, g_IAP.m_IAPJNI); env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Restore, g_IAP.m_IAPJNI);
assert(top == lua_gettop(L));
lua_pushboolean(L, 1); lua_pushboolean(L, 1);
return 1; return 1;
} }
static int IAP_SetListener(lua_State* L) static int IAP_SetListener(lua_State* L)
{ {
DM_LUA_STACK_CHECK(L, 0);
IAP* iap = &g_IAP; IAP* iap = &g_IAP;
bool had_previous = iap->m_Listener != 0; bool had_previous = iap->m_Listener != 0;
@ -212,6 +220,8 @@ static int IAP_SetListener(lua_State* L)
static int IAP_GetProviderId(lua_State* L) static int IAP_GetProviderId(lua_State* L)
{ {
DM_LUA_STACK_CHECK(L, 1);
lua_pushinteger(L, g_IAP.m_ProviderId); lua_pushinteger(L, g_IAP.m_ProviderId);
return 1; return 1;
} }
@ -296,23 +306,9 @@ static void HandleProductResult(const IAPCommand* cmd)
} }
if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) {
dmJson::Document doc; const char* json = (const char*)cmd->m_Data;
dmJson::Result r = dmJson::Parse((const char*) cmd->m_Data, &doc); dmScript::JsonToLua(L, json, strlen(json)); // throws lua error if it fails
if (r == dmJson::RESULT_OK && doc.m_NodeCount > 0) {
char err_str[128];
if (dmScript::JsonToLua(L, &doc, 0, err_str, sizeof(err_str)) < 0) {
dmLogError("Failed converting product result JSON to Lua; %s", err_str);
lua_pushnil(L); lua_pushnil(L);
IAP_PushError(L, "failed to convert JSON to Lua for product response", REASON_UNSPECIFIED);
} else {
lua_pushnil(L);
}
} else {
dmLogError("Failed to parse product response (%d)", r);
lua_pushnil(L);
IAP_PushError(L, "failed to parse product response", REASON_UNSPECIFIED);
}
dmJson::Free(&doc);
} else { } else {
dmLogError("IAP error %d", cmd->m_ResponseCode); dmLogError("IAP error %d", cmd->m_ResponseCode);
lua_pushnil(L); lua_pushnil(L);
@ -346,23 +342,9 @@ static void HandlePurchaseResult(const IAPCommand* cmd)
if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) {
if (cmd->m_Data != 0) { if (cmd->m_Data != 0) {
dmJson::Document doc; const char* json = (const char*)cmd->m_Data;
dmJson::Result r = dmJson::Parse((const char*) cmd->m_Data, &doc); dmScript::JsonToLua(L, json, strlen(json)); // throws lua error if it fails
if (r == dmJson::RESULT_OK && doc.m_NodeCount > 0) {
char err_str[128];
if (dmScript::JsonToLua(L, &doc, 0, err_str, sizeof(err_str)) < 0) {
dmLogError("Failed converting purchase JSON result to Lua; %s", err_str);
lua_pushnil(L); lua_pushnil(L);
IAP_PushError(L, "failed to convert purchase response JSON to Lua", REASON_UNSPECIFIED);
} else {
lua_pushnil(L);
}
} else {
dmLogError("Failed to parse purchase response (%d)", r);
lua_pushnil(L);
IAP_PushError(L, "failed to parse purchase response", REASON_UNSPECIFIED);
}
dmJson::Free(&doc);
} else { } else {
dmLogError("IAP error, purchase response was null"); dmLogError("IAP error, purchase response was null");
lua_pushnil(L); lua_pushnil(L);
@ -409,7 +391,7 @@ static dmExtension::Result InitializeIAP(dmExtension::Params* params)
jclass iap_jni_class = dmAndroid::LoadClass(env, "com.defold.iap.IapJNI"); jclass iap_jni_class = dmAndroid::LoadClass(env, "com.defold.iap.IapJNI");
g_IAP.m_List = env->GetMethodID(iap_class, "listItems", "(Ljava/lang/String;Lcom/defold/iap/IListProductsListener;J)V"); g_IAP.m_List = env->GetMethodID(iap_class, "listItems", "(Ljava/lang/String;Lcom/defold/iap/IListProductsListener;J)V");
g_IAP.m_Buy = env->GetMethodID(iap_class, "buy", "(Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V"); g_IAP.m_Buy = env->GetMethodID(iap_class, "buy", "(Ljava/lang/String;Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V");
g_IAP.m_Restore = env->GetMethodID(iap_class, "restore", "(Lcom/defold/iap/IPurchaseListener;)V"); g_IAP.m_Restore = env->GetMethodID(iap_class, "restore", "(Lcom/defold/iap/IPurchaseListener;)V");
g_IAP.m_Stop = env->GetMethodID(iap_class, "stop", "()V"); g_IAP.m_Stop = env->GetMethodID(iap_class, "stop", "()V");
g_IAP.m_ProcessPendingConsumables = env->GetMethodID(iap_class, "processPendingConsumables", "(Lcom/defold/iap/IPurchaseListener;)V"); g_IAP.m_ProcessPendingConsumables = env->GetMethodID(iap_class, "processPendingConsumables", "(Lcom/defold/iap/IPurchaseListener;)V");

View File

@ -46,23 +46,8 @@ static void IAPList_Callback(void* luacallback, const char* result_json)
if(result_json != 0) if(result_json != 0)
{ {
dmJson::Document doc; dmScript::JsonToLua(L, result_json, strlen(result_json)); // throws lua error if it fails
dmJson::Result r = dmJson::Parse(result_json, &doc);
if (r == dmJson::RESULT_OK && doc.m_NodeCount > 0) {
char err_str[128];
if (dmScript::JsonToLua(L, &doc, 0, err_str, sizeof(err_str)) < 0) {
dmLogError("Failed converting list result JSON to Lua; %s", err_str);
lua_pushnil(L); lua_pushnil(L);
IAP_PushError(L, "Failed converting list result JSON to Lua", REASON_UNSPECIFIED);
} else {
lua_pushnil(L);
}
} else {
dmLogError("Failed to parse list result JSON (%d)", r);
lua_pushnil(L);
IAP_PushError(L, "Failed to parse list result JSON", REASON_UNSPECIFIED);
}
dmJson::Free(&doc);
} }
else else
{ {
@ -112,23 +97,8 @@ static void IAPListener_Callback(void* luacallback, const char* result_json, int
} }
if (result_json) { if (result_json) {
dmJson::Document doc; dmScript::JsonToLua(L, result_json, strlen(result_json)); // throws lua error if it fails
dmJson::Result r = dmJson::Parse(result_json, &doc);
if (r == dmJson::RESULT_OK && doc.m_NodeCount > 0) {
char err_str[128];
if (dmScript::JsonToLua(L, &doc, 0, err_str, sizeof(err_str)) < 0) {
dmLogError("Failed converting purchase result JSON to Lua; %s", err_str);
lua_pushnil(L); lua_pushnil(L);
IAP_PushError(L, "failed converting purchase result JSON to Lua", REASON_UNSPECIFIED);
} else {
lua_pushnil(L);
}
} else {
dmLogError("Failed to parse purchase response (%d)", r);
lua_pushnil(L);
IAP_PushError(L, "failed to parse purchase response", REASON_UNSPECIFIED);
}
dmJson::Free(&doc);
} else { } else {
lua_pushnil(L); lua_pushnil(L);
switch(error_code) switch(error_code)

View File

@ -21,7 +21,7 @@ struct DM_ALIGNED(16) IAPCommand
// Used for storing eventual callback info (if needed) // Used for storing eventual callback info (if needed)
dmScript::LuaCallbackInfo* m_Callback; dmScript::LuaCallbackInfo* m_Callback;
// THe actual command payload // The actual command payload
int32_t m_Command; int32_t m_Command;
int32_t m_ResponseCode; int32_t m_ResponseCode;
void* m_Data; void* m_Data;

View File

@ -81,7 +81,7 @@ public class IapAmazon implements PurchasingListener {
} }
} }
public void buy(final String product, final IPurchaseListener listener) { public void buy(final String product, final String token, final IPurchaseListener listener) {
synchronized (purchaseListeners) { synchronized (purchaseListeners) {
RequestId req = PurchasingService.purchase(product); RequestId req = PurchasingService.purchase(product);
if (req != null) { if (req != null) {

View File

@ -19,26 +19,35 @@ import android.util.Log;
import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClient;
import com.android.billingclient.api.BillingClient.BillingResponseCode; import com.android.billingclient.api.BillingClient.BillingResponseCode;
import com.android.billingclient.api.BillingClient.SkuType; import com.android.billingclient.api.BillingClient.ProductType;
import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.BillingResult;
import com.android.billingclient.api.PendingPurchasesParams;
import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase;
import com.android.billingclient.api.Purchase.PurchasesResult;
import com.android.billingclient.api.Purchase.PurchaseState; import com.android.billingclient.api.Purchase.PurchaseState;
import com.android.billingclient.api.SkuDetails; import com.android.billingclient.api.ProductDetails;
import com.android.billingclient.api.ProductDetails.OneTimePurchaseOfferDetails;
import com.android.billingclient.api.ProductDetails.PricingPhases;
import com.android.billingclient.api.ProductDetails.PricingPhase;
import com.android.billingclient.api.ProductDetails.RecurrenceMode;
import com.android.billingclient.api.ProductDetails.SubscriptionOfferDetails;
import com.android.billingclient.api.ConsumeParams; import com.android.billingclient.api.ConsumeParams;
import com.android.billingclient.api.BillingFlowParams; import com.android.billingclient.api.BillingFlowParams;
import com.android.billingclient.api.SkuDetailsParams; import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams;
import com.android.billingclient.api.QueryPurchasesParams;
import com.android.billingclient.api.QueryProductDetailsParams;
import com.android.billingclient.api.QueryProductDetailsParams.Product;
import com.android.billingclient.api.AcknowledgePurchaseParams; import com.android.billingclient.api.AcknowledgePurchaseParams;
import com.android.billingclient.api.PurchasesUpdatedListener; import com.android.billingclient.api.PurchasesUpdatedListener;
import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingClientStateListener;
import com.android.billingclient.api.ConsumeResponseListener; import com.android.billingclient.api.ConsumeResponseListener;
import com.android.billingclient.api.SkuDetailsResponseListener; import com.android.billingclient.api.PurchasesResponseListener;
import com.android.billingclient.api.ProductDetailsResponseListener;
import com.android.billingclient.api.AcknowledgePurchaseResponseListener; import com.android.billingclient.api.AcknowledgePurchaseResponseListener;
public class IapGooglePlay implements PurchasesUpdatedListener { public class IapGooglePlay implements PurchasesUpdatedListener {
public static final String TAG = "IapGooglePlay"; public static final String TAG = "IapGooglePlay";
private Map<String, SkuDetails> products = new HashMap<String, SkuDetails>(); private Map<String, ProductDetails> products = new HashMap<String, ProductDetails>();
private BillingClient billingClient; private BillingClient billingClient;
private IPurchaseListener purchaseListener; private IPurchaseListener purchaseListener;
private boolean autoFinishTransactions; private boolean autoFinishTransactions;
@ -48,7 +57,8 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
this.activity = activity; this.activity = activity;
this.autoFinishTransactions = autoFinishTransactions; this.autoFinishTransactions = autoFinishTransactions;
billingClient = BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases().build(); PendingPurchasesParams pendingPurchasesParams = PendingPurchasesParams.newBuilder().enableOneTimeProducts().build();
billingClient = BillingClient.newBuilder(activity).setListener(this).enablePendingPurchases(pendingPurchasesParams).build();
billingClient.startConnection(new BillingClientStateListener() { billingClient.startConnection(new BillingClientStateListener() {
@Override @Override
public void onBillingSetupFinished(BillingResult billingResult) { public void onBillingSetupFinished(BillingResult billingResult) {
@ -110,16 +120,67 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
return p.toString(); return p.toString();
} }
private JSONObject convertSkuDetails(SkuDetails skuDetails) { private JSONArray convertSubscriptionOfferPricingPhases(SubscriptionOfferDetails details) {
JSONObject p = new JSONObject(); JSONArray a = new JSONArray();
try { try {
p.put("price_string", skuDetails.getPrice()); List<PricingPhase> pricingPhases = details.getPricingPhases().getPricingPhaseList();
p.put("ident", skuDetails.getSku()); for (PricingPhase pricingPhase : pricingPhases) {
p.put("currency_code", skuDetails.getPriceCurrencyCode()); JSONObject o = new JSONObject();
p.put("price", skuDetails.getPriceAmountMicros() * 0.000001); o.put("price_string", pricingPhase.getFormattedPrice());
o.put("price", pricingPhase.getPriceAmountMicros() * 0.000001);
o.put("currency_code", pricingPhase.getPriceCurrencyCode());
o.put("billing_period", pricingPhase.getBillingPeriod());
o.put("billing_cycle_count", pricingPhase.getBillingCycleCount());
switch (pricingPhase.getRecurrenceMode()) {
case RecurrenceMode.FINITE_RECURRING:
o.put("recurrence_mode", "FINITE");
break;
case RecurrenceMode.INFINITE_RECURRING:
o.put("recurrence_mode", "INFINITE");
break;
default:
case RecurrenceMode.NON_RECURRING:
o.put("recurrence_mode", "NONE");
break;
}
a.put(o);
}
} }
catch(JSONException e) { catch(JSONException e) {
Log.wtf(TAG, "Failed to convert sku details", e); Log.wtf(TAG, "Failed to convert subscription offer pricing phases", e);
}
return a;
}
private JSONObject convertProductDetails(ProductDetails productDetails) {
JSONObject p = new JSONObject();
try {
p.put("ident", productDetails.getProductId());
p.put("title", productDetails.getTitle());
p.put("description", productDetails.getDescription());
if (productDetails.getProductType().equals(ProductType.INAPP)) {
OneTimePurchaseOfferDetails offerDetails = productDetails.getOneTimePurchaseOfferDetails();
p.put("price_string", offerDetails.getFormattedPrice());
p.put("currency_code", offerDetails.getPriceCurrencyCode());
p.put("price", offerDetails.getPriceAmountMicros() * 0.000001);
}
else if (productDetails.getProductType().equals(ProductType.SUBS)) {
List<SubscriptionOfferDetails> subscriptionOfferDetails = productDetails.getSubscriptionOfferDetails();
JSONArray a = new JSONArray();
for (SubscriptionOfferDetails offerDetails : subscriptionOfferDetails) {
JSONObject o = new JSONObject();
o.put("token", offerDetails.getOfferToken());
o.put("pricing", convertSubscriptionOfferPricingPhases(offerDetails));
a.put(o);
}
p.put("subscriptions", a);
}
else {
Log.i(TAG, "convertProductDetails() unknown product type " + productDetails.getProductType());
}
}
catch(JSONException e) {
Log.wtf(TAG, "Failed to convert product details", e);
} }
return p; return p;
} }
@ -162,7 +223,6 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
case BillingResponseCode.OK: case BillingResponseCode.OK:
defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_OK; defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_OK;
break; break;
case BillingResponseCode.SERVICE_TIMEOUT:
case BillingResponseCode.SERVICE_UNAVAILABLE: case BillingResponseCode.SERVICE_UNAVAILABLE:
case BillingResponseCode.SERVICE_DISCONNECTED: case BillingResponseCode.SERVICE_DISCONNECTED:
defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE; defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE;
@ -170,6 +230,9 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
case BillingResponseCode.USER_CANCELED: case BillingResponseCode.USER_CANCELED:
defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_USER_CANCELED; defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_USER_CANCELED;
break; break;
case BillingResponseCode.NETWORK_ERROR: // new in Play Billing Library 6.0.0
defoldResponse = IapJNI.BILLING_RESPONSE_RESULT_NETWORK_ERROR;
break;
case BillingResponseCode.FEATURE_NOT_SUPPORTED: case BillingResponseCode.FEATURE_NOT_SUPPORTED:
case BillingResponseCode.ERROR: case BillingResponseCode.ERROR:
default: default:
@ -184,19 +247,18 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
return billingResponseCodeToDefoldResponse(result.getResponseCode()); return billingResponseCodeToDefoldResponse(result.getResponseCode());
} }
/** private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, int billingResultCode, String purchaseData) {
* Query Google Play for purchases done within the app. if (purchaseListener == null) {
*/ Log.w(TAG, "Received billing result but no listener has been set");
private List<Purchase> queryPurchases(final String type) { return;
PurchasesResult result = billingClient.queryPurchases(type);
List<Purchase> purchases = result.getPurchasesList();
if (purchases == null) {
purchases = new ArrayList<Purchase>();
} }
if (result.getBillingResult().getResponseCode() != BillingResponseCode.OK) { purchaseListener.onPurchaseResult(billingResultCode, purchaseData);
Log.e(TAG, "Unable to query pending purchases: " + result.getBillingResult().getDebugMessage());
} }
return purchases; private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, BillingResult billingResult, Purchase purchase) {
invokeOnPurchaseResultListener(purchaseListener, billingResultToDefoldResponse(billingResult), convertPurchase(purchase));
}
private void invokeOnPurchaseResultListener(IPurchaseListener purchaseListener, BillingResult billingResult) {
invokeOnPurchaseResultListener(purchaseListener, billingResultToDefoldResponse(billingResult), "");
} }
/** /**
@ -207,13 +269,35 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
*/ */
public void processPendingConsumables(final IPurchaseListener purchaseListener) { public void processPendingConsumables(final IPurchaseListener purchaseListener) {
Log.d(TAG, "processPendingConsumables()"); Log.d(TAG, "processPendingConsumables()");
List<Purchase> purchasesList = new ArrayList<Purchase>();
purchasesList.addAll(queryPurchases(SkuType.INAPP));
purchasesList.addAll(queryPurchases(SkuType.SUBS)); PurchasesResponseListener purchasesListener = new PurchasesResponseListener() {
for (Purchase purchase : purchasesList) { private List<Purchase> allPurchases = new ArrayList<Purchase>();
private int queries = 2;
@Override
public void onQueryPurchasesResponse(BillingResult billingResult, List<Purchase> purchases) {
if (billingResult.getResponseCode() != BillingResponseCode.OK) {
Log.e(TAG, "Unable to query pending purchases: " + billingResult.getDebugMessage());
}
if (purchases != null) {
allPurchases.addAll(purchases);
}
// we're finished when we have queried for both in-app and subs
queries--;
if (queries == 0) {
for (Purchase purchase : allPurchases) {
handlePurchase(purchase, purchaseListener); handlePurchase(purchase, purchaseListener);
} }
} }
}
};
final QueryPurchasesParams inappParams = QueryPurchasesParams.newBuilder().setProductType(ProductType.INAPP).build();
final QueryPurchasesParams subsParams = QueryPurchasesParams.newBuilder().setProductType(ProductType.SUBS).build();
billingClient.queryPurchasesAsync(inappParams, purchasesListener);
billingClient.queryPurchasesAsync(subsParams, purchasesListener);
}
/** /**
* Consume a purchase. This will acknowledge the purchase and make it * Consume a purchase. This will acknowledge the purchase and make it
@ -221,7 +305,7 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
*/ */
private void consumePurchase(final String purchaseToken, final ConsumeResponseListener consumeListener) { private void consumePurchase(final String purchaseToken, final ConsumeResponseListener consumeListener) {
Log.d(TAG, "consumePurchase() " + purchaseToken); Log.d(TAG, "consumePurchase() " + purchaseToken);
ConsumeParams consumeParams = ConsumeParams.newBuilder() final ConsumeParams consumeParams = ConsumeParams.newBuilder()
.setPurchaseToken(purchaseToken) .setPurchaseToken(purchaseToken)
.build(); .build();
@ -240,7 +324,7 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
// note: we only call the purchase listener if an error happens // note: we only call the purchase listener if an error happens
if (billingResult.getResponseCode() != BillingResponseCode.OK) { if (billingResult.getResponseCode() != BillingResponseCode.OK) {
Log.e(TAG, "Unable to consume purchase: " + billingResult.getDebugMessage()); Log.e(TAG, "Unable to consume purchase: " + billingResult.getDebugMessage());
purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); invokeOnPurchaseResultListener(purchaseListener, billingResult);
} }
} }
}); });
@ -263,7 +347,7 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
// note: we only call the purchase listener if an error happens // note: we only call the purchase listener if an error happens
if (billingResult.getResponseCode() != BillingResponseCode.OK) { if (billingResult.getResponseCode() != BillingResponseCode.OK) {
Log.e(TAG, "Unable to acknowledge purchase: " + billingResult.getDebugMessage()); Log.e(TAG, "Unable to acknowledge purchase: " + billingResult.getDebugMessage());
purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); invokeOnPurchaseResultListener(purchaseListener, billingResult);
} }
} }
}); });
@ -273,7 +357,7 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
* Handle a purchase. If the extension is configured to automatically * Handle a purchase. If the extension is configured to automatically
* finish transactions the purchase will be immediately consumed. Otherwise * finish transactions the purchase will be immediately consumed. Otherwise
* the product will be returned via the listener without being consumed. * the product will be returned via the listener without being consumed.
* NOTE: Billing 3.0 requires purchases to be acknowledged within 3 days of * NOTE: Billing 3.0+ requires purchases to be acknowledged within 3 days of
* purchase unless they are consumed. * purchase unless they are consumed.
*/ */
private void handlePurchase(final Purchase purchase, final IPurchaseListener purchaseListener) { private void handlePurchase(final Purchase purchase, final IPurchaseListener purchaseListener) {
@ -282,12 +366,12 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
@Override @Override
public void onConsumeResponse(BillingResult billingResult, String purchaseToken) { public void onConsumeResponse(BillingResult billingResult, String purchaseToken) {
Log.d(TAG, "handlePurchase() response code " + billingResult.getResponseCode() + " purchaseToken: " + purchaseToken); Log.d(TAG, "handlePurchase() response code " + billingResult.getResponseCode() + " purchaseToken: " + purchaseToken);
purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), convertPurchase(purchase)); invokeOnPurchaseResultListener(purchaseListener, billingResult, purchase);
} }
}); });
} }
else { else {
purchaseListener.onPurchaseResult(billingResponseCodeToDefoldResponse(BillingResponseCode.OK), convertPurchase(purchase)); invokeOnPurchaseResultListener(purchaseListener, billingResponseCodeToDefoldResponse(BillingResponseCode.OK), convertPurchase(purchase));
} }
} }
@ -296,13 +380,17 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
*/ */
@Override @Override
public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) { public void onPurchasesUpdated(BillingResult billingResult, List<Purchase> purchases) {
if (billingResult.getResponseCode() == BillingResponseCode.OK && purchases != null) { if (billingResult.getResponseCode() == BillingResponseCode.OK) {
if (purchases != null && !purchases.isEmpty()) {
for (Purchase purchase : purchases) { for (Purchase purchase : purchases) {
if (purchase != null) {
handlePurchase(purchase, this.purchaseListener); handlePurchase(purchase, this.purchaseListener);
} }
} }
}
}
else { else {
this.purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); invokeOnPurchaseResultListener(this.purchaseListener, billingResult);
} }
} }
@ -310,42 +398,53 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
* Buy a product. This method stores the listener and uses it in the * Buy a product. This method stores the listener and uses it in the
* onPurchasesUpdated() callback. * onPurchasesUpdated() callback.
*/ */
private void buyProduct(SkuDetails sku, final IPurchaseListener purchaseListener) { private void buyProduct(ProductDetails pd, final String token, final IPurchaseListener purchaseListener) {
this.purchaseListener = purchaseListener; this.purchaseListener = purchaseListener;
BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder() List<ProductDetailsParams> productDetailsParams = new ArrayList();
.setSkuDetails(sku) if (pd.getProductType().equals(ProductType.SUBS)) {
.build(); productDetailsParams.add(ProductDetailsParams.newBuilder().setProductDetails(pd).setOfferToken(token).build());
}
else {
productDetailsParams.add(ProductDetailsParams.newBuilder().setProductDetails(pd).build());
}
final BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList(productDetailsParams).build();
BillingResult billingResult = billingClient.launchBillingFlow(this.activity, billingFlowParams); BillingResult billingResult = billingClient.launchBillingFlow(this.activity, billingFlowParams);
if (billingResult.getResponseCode() != BillingResponseCode.OK) { if (billingResult.getResponseCode() != BillingResponseCode.OK) {
Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage()); Log.e(TAG, "Purchase failed: " + billingResult.getDebugMessage());
purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); invokeOnPurchaseResultListener(purchaseListener, billingResult);
} }
} }
/** /**
* Called from Lua. * Called from Lua.
*/ */
public void buy(final String product, final IPurchaseListener purchaseListener) { public void buy(final String product, final String token, final IPurchaseListener purchaseListener) {
Log.d(TAG, "buy()"); Log.d(TAG, "buy()");
SkuDetails sku = this.products.get(product); ProductDetails pd = this.products.get(product);
if (sku != null) { if (pd != null) {
buyProduct(sku, purchaseListener); buyProduct(pd, token, purchaseListener);
} }
else { else {
List<String> skuList = new ArrayList<String>(); List<String> productList = new ArrayList<String>();
skuList.add(product); productList.add(product);
querySkuDetailsAsync(skuList, new SkuDetailsResponseListener() { queryProductDetailsAsync(productList, new ProductDetailsResponseListener() {
@Override @Override
public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetailsList) { public void onProductDetailsResponse(BillingResult billingResult, List<ProductDetails> productDetailsList) {
if (billingResult.getResponseCode() == BillingResponseCode.OK && (skuDetailsList != null) && !skuDetailsList.isEmpty()) { if (billingResult.getResponseCode() == BillingResponseCode.OK && (productDetailsList != null) && !productDetailsList.isEmpty()) {
buyProduct(skuDetailsList.get(0), purchaseListener); for (ProductDetails productDetails : productDetailsList) {
if (productDetails != null) {
buyProduct(productDetails, token, purchaseListener);
break;
}
}
} }
else { else {
Log.e(TAG, "Unable to get product details before buying: " + billingResult.getDebugMessage()); Log.e(TAG, "Unable to get product details before buying: " + billingResult.getDebugMessage());
purchaseListener.onPurchaseResult(billingResultToDefoldResponse(billingResult), ""); invokeOnPurchaseResultListener(purchaseListener, billingResult);
} }
} }
}); });
@ -356,30 +455,45 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
* Get details for a list of products. The products can be a mix of * Get details for a list of products. The products can be a mix of
* in-app products and subscriptions. * in-app products and subscriptions.
*/ */
private void querySkuDetailsAsync(final List<String> skuList, final SkuDetailsResponseListener listener) { private void queryProductDetailsAsync(final List<String> productList, final ProductDetailsResponseListener listener) {
SkuDetailsResponseListener detailsListener = new SkuDetailsResponseListener() { ProductDetailsResponseListener detailsListener = new ProductDetailsResponseListener() {
private List<SkuDetails> allSkuDetails = new ArrayList<SkuDetails>(); private List<ProductDetails> allProductDetails = new ArrayList<ProductDetails>();
private int queries = 2; private int queries = 2;
@Override @Override
public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetails) { public void onProductDetailsResponse(BillingResult billingResult, List<ProductDetails> productDetails) {
if (skuDetails != null) { if (productDetails != null && !productDetails.isEmpty()) {
// cache skus (cache will be used to speed up buying) // cache products (cache will be used to speed up buying)
for (SkuDetails sd : skuDetails) { for (ProductDetails pd : productDetails) {
IapGooglePlay.this.products.put(sd.getSku(), sd); if (pd != null) {
IapGooglePlay.this.products.put(pd.getProductId(), pd);
} }
// add to list of all sku details }
allSkuDetails.addAll(skuDetails); // add to list of all product details
allProductDetails.addAll(productDetails);
} }
// we're finished when we have queried for both in-app and subs // we're finished when we have queried for both in-app and subs
queries--; queries--;
if (queries == 0) { if (queries == 0) {
listener.onSkuDetailsResponse(billingResult, allSkuDetails); listener.onProductDetailsResponse(billingResult, allProductDetails);
} }
} }
}; };
billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder().setSkusList(skuList).setType(SkuType.INAPP).build(), detailsListener);
billingClient.querySkuDetailsAsync(SkuDetailsParams.newBuilder().setSkusList(skuList).setType(SkuType.SUBS).build(), detailsListener); // we don't know if a product is a subscription or inapp product type
// instread we create two product lists from the same set of products and use INAPP for one and SUBS for the other
List<Product> inappProductList = new ArrayList();
List<Product> subsProductList = new ArrayList();
for (String productId : productList) {
inappProductList.add(Product.newBuilder().setProductId(productId).setProductType(ProductType.INAPP).build());
subsProductList.add(Product.newBuilder().setProductId(productId).setProductType(ProductType.SUBS).build());
}
// do one query per product type
final QueryProductDetailsParams inappParams = QueryProductDetailsParams.newBuilder().setProductList(inappProductList).build();
final QueryProductDetailsParams subsParams = QueryProductDetailsParams.newBuilder().setProductList(subsProductList).build();
billingClient.queryProductDetailsAsync(inappParams, detailsListener);
billingClient.queryProductDetailsAsync(subsParams, detailsListener);
} }
/** /**
@ -388,21 +502,23 @@ public class IapGooglePlay implements PurchasesUpdatedListener {
public void listItems(final String products, final IListProductsListener productsListener, final long commandPtr) { public void listItems(final String products, final IListProductsListener productsListener, final long commandPtr) {
Log.d(TAG, "listItems()"); Log.d(TAG, "listItems()");
// create list of skus from comma separated string // create list of product ids from comma separated string
List<String> skuList = new ArrayList<String>(); List<String> productList = new ArrayList<String>();
for (String p : products.split(",")) { for (String p : products.split(",")) {
if (p.trim().length() > 0) { if (p.trim().length() > 0) {
skuList.add(p); productList.add(p);
} }
} }
querySkuDetailsAsync(skuList, new SkuDetailsResponseListener() { queryProductDetailsAsync(productList, new ProductDetailsResponseListener() {
@Override @Override
public void onSkuDetailsResponse(BillingResult billingResult, List<SkuDetails> skuDetails) { public void onProductDetailsResponse(BillingResult billingResult, List<ProductDetails> productDetails) {
JSONArray a = new JSONArray(); JSONArray a = new JSONArray();
if (billingResult.getResponseCode() == BillingResponseCode.OK) { if ((billingResult.getResponseCode() == BillingResponseCode.OK) && (productDetails != null) && !productDetails.isEmpty()) {
for (SkuDetails sd : skuDetails) { for (ProductDetails pd : productDetails) {
a.put(convertSkuDetails(sd)); if (pd != null) {
a.put(convertProductDetails(pd));
}
} }
} }
else { else {

View File

@ -18,6 +18,7 @@ public class IapJNI implements IListProductsListener, IPurchaseListener {
public static final int BILLING_RESPONSE_RESULT_ERROR = 6; public static final int BILLING_RESPONSE_RESULT_ERROR = 6;
public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7;
public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8;
public static final int BILLING_RESPONSE_RESULT_NETWORK_ERROR = 9;
public IapJNI() { public IapJNI() {
} }

View File

@ -11,12 +11,12 @@ height = 1136
[android] [android]
input_method = HiddenInputField input_method = HiddenInputField
package = com.defold.extension.iap package = com.defold.extension.iap
version_code = 7 version_code = 9
target_sdk_version = 29 minimum_sdk_version = 21
[project] [project]
title = extension-iap title = extension-iap
dependencies = https://github.com/andsve/dirtylarry/archive/master.zip dependencies#0 = https://github.com/andsve/dirtylarry/archive/master.zip
[library] [library]
include_dirs = extension-iap include_dirs = extension-iap

View File

@ -1,7 +1,7 @@
script: "/main/main.gui_script" script: "/main/main.gui_script"
fonts { fonts {
name: "system_font" name: "default"
font: "/builtins/fonts/system_font.font" font: "/builtins/fonts/default.font"
} }
background_color { background_color {
x: 0.0 x: 0.0
@ -47,6 +47,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -102,6 +104,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -166,6 +172,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -205,6 +215,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -261,6 +273,10 @@ nodes {
overridden_fields: 4 overridden_fields: 4
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -325,6 +341,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -360,7 +380,7 @@ nodes {
type: TYPE_TEXT type: TYPE_TEXT
blend_mode: BLEND_MODE_ALPHA blend_mode: BLEND_MODE_ALPHA
text: "<text>" text: "<text>"
font: "system_font" font: "default"
id: "log" id: "log"
xanchor: XANCHOR_NONE xanchor: XANCHOR_NONE
yanchor: YANCHOR_NONE yanchor: YANCHOR_NONE
@ -387,6 +407,10 @@ nodes {
template_node_child: false template_node_child: false
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -426,6 +450,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -481,6 +507,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -545,6 +575,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -584,6 +618,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -639,6 +675,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -703,6 +743,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -742,6 +786,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -798,6 +844,10 @@ nodes {
overridden_fields: 4 overridden_fields: 4
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -862,6 +912,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -901,6 +955,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -956,6 +1012,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1020,6 +1080,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1059,6 +1123,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/button.gui" template: "/dirtylarry/button.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -1115,6 +1181,10 @@ nodes {
overridden_fields: 4 overridden_fields: 4
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1179,6 +1249,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1218,6 +1292,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/checkbox_label.gui" template: "/dirtylarry/checkbox_label.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -1273,6 +1349,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1337,6 +1417,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1376,6 +1460,8 @@ nodes {
alpha: 1.0 alpha: 1.0
template: "/dirtylarry/checkbox_label.gui" template: "/dirtylarry/checkbox_label.gui"
template_node_child: false template_node_child: false
custom_type: 0
enabled: true
} }
nodes { nodes {
position { position {
@ -1431,6 +1517,10 @@ nodes {
alpha: 1.0 alpha: 1.0
template_node_child: true template_node_child: true
size_mode: SIZE_MODE_MANUAL size_mode: SIZE_MODE_MANUAL
custom_type: 0
enabled: true
visible: true
material: ""
} }
nodes { nodes {
position { position {
@ -1495,6 +1585,10 @@ nodes {
template_node_child: true template_node_child: true
text_leading: 1.0 text_leading: 1.0
text_tracking: 0.0 text_tracking: 0.0
custom_type: 0
enabled: true
visible: true
material: ""
} }
material: "/builtins/materials/gui.material" material: "/builtins/materials/gui.material"
adjust_reference: ADJUST_REFERENCE_PARENT adjust_reference: ADJUST_REFERENCE_PARENT

View File

@ -3,7 +3,7 @@ local dirtylarry = require "dirtylarry/dirtylarry"
local GOLDBARS_SMALL = "com.defold.iap.goldbar.small" local GOLDBARS_SMALL = "com.defold.iap.goldbar.small"
local GOLDBARS_MEDIUM = "com.defold.iap.goldbar.medium" local GOLDBARS_MEDIUM = "com.defold.iap.goldbar.medium"
local GOLDBARS_LARGE = "com.defold.iap.goldbar.large" local GOLDBARS_LARGE = "com.defold.iap.goldbar.large"
local SUBSCRIPTION = "com.defold.iap.subscription" local SUBSCRIPTION = "com.defold.iap.subscription.one"
local NON_CONSUMABLE = "com.defold.iap.removeads" local NON_CONSUMABLE = "com.defold.iap.removeads"
local items = { local items = {
@ -44,7 +44,13 @@ end
local function buy(id) local function buy(id)
log("iap.buy() " .. id) log("iap.buy() " .. id)
iap.buy(id) local options = {}
local item = available_items[id]
if item.subscriptions then
local subscription = item.subscriptions[1]
options.token = subscription.token
end
iap.buy(id, options)
end end
local function restore() local function restore()
@ -66,6 +72,7 @@ local function list()
for k,p in pairs(products) do for k,p in pairs(products) do
available_items[p.ident] = p available_items[p.ident] = p
log("Item %s", p.ident) log("Item %s", p.ident)
pprint(p)
local button = item_buttons[p.ident] local button = item_buttons[p.ident]
if button then if button then
gui.set_color(gui.get_node(button.."/larrylabel"), vmath.vector4(1,1,1,1)) gui.set_color(gui.get_node(button.."/larrylabel"), vmath.vector4(1,1,1,1))