From 0fa8400cac0434c92ea7bf5a9e1bf5ba755f8264 Mon Sep 17 00:00:00 2001 From: Alexey Gulev Date: Tue, 17 Sep 2019 16:50:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 10 + extension-iap/ext.manifest | 15 + .../lib/android/in-app-purchasing-2.0.61.jar | Bin 0 -> 98823 bytes extension-iap/lib/web/library_facebook_iap.js | 169 ++++ .../manifests/android/AndroidManifest.xml | 25 + .../manifests/android/extension-adinfo.pro | 4 + extension-iap/src/iap.h | 46 + extension-iap/src/iap_android.cpp | 583 +++++++++++++ extension-iap/src/iap_emscripten.cpp | 311 +++++++ extension-iap/src/iap_ios.mm | 806 ++++++++++++++++++ extension-iap/src/iap_null.cpp | 9 + extension-iap/src/iap_private.cpp | 99 +++ extension-iap/src/iap_private.h | 28 + .../vending/billing/IInAppBillingService.java | 501 +++++++++++ .../com/defold/iap/IListProductsListener.java | 5 + .../com/defold/iap/IPurchaseListener.java | 5 + .../src/java/com/defold/iap/IapAmazon.java | 307 +++++++ .../java/com/defold/iap/IapGooglePlay.java | 496 +++++++++++ .../com/defold/iap/IapGooglePlayActivity.java | 371 ++++++++ .../src/java/com/defold/iap/IapJNI.java | 31 + game.project | 23 + input/game.input_binding | 4 + main/main.collection | 37 + main/main.gui | 76 ++ main/main.gui_script | 3 + 25 files changed, 3964 insertions(+) create mode 100644 .gitignore create mode 100644 extension-iap/ext.manifest create mode 100644 extension-iap/lib/android/in-app-purchasing-2.0.61.jar create mode 100644 extension-iap/lib/web/library_facebook_iap.js create mode 100644 extension-iap/manifests/android/AndroidManifest.xml create mode 100644 extension-iap/manifests/android/extension-adinfo.pro create mode 100644 extension-iap/src/iap.h create mode 100644 extension-iap/src/iap_android.cpp create mode 100644 extension-iap/src/iap_emscripten.cpp create mode 100644 extension-iap/src/iap_ios.mm create mode 100644 extension-iap/src/iap_null.cpp create mode 100644 extension-iap/src/iap_private.cpp create mode 100644 extension-iap/src/iap_private.h create mode 100644 extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java create mode 100644 extension-iap/src/java/com/defold/iap/IListProductsListener.java create mode 100644 extension-iap/src/java/com/defold/iap/IPurchaseListener.java create mode 100644 extension-iap/src/java/com/defold/iap/IapAmazon.java create mode 100644 extension-iap/src/java/com/defold/iap/IapGooglePlay.java create mode 100644 extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java create mode 100644 extension-iap/src/java/com/defold/iap/IapJNI.java create mode 100644 game.project create mode 100644 input/game.input_binding create mode 100644 main/main.collection create mode 100644 main/main.gui create mode 100644 main/main.gui_script diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a32d29f --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/.internal +/build +.externalToolBuilders +.DS_Store +Thumbs.db +.lock-wscript +*.pyc +.project +.cproject +builtins \ No newline at end of file diff --git a/extension-iap/ext.manifest b/extension-iap/ext.manifest new file mode 100644 index 0000000..8cae51e --- /dev/null +++ b/extension-iap/ext.manifest @@ -0,0 +1,15 @@ +name: IAPExt + +platforms: + armv7-ios: + context: + weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] + + arm64-ios: + context: + weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] + + x86_64-ios: + context: + weakFrameworks: ['StoreKit', 'UIKit', 'Foundation'] + \ No newline at end of file diff --git a/extension-iap/lib/android/in-app-purchasing-2.0.61.jar b/extension-iap/lib/android/in-app-purchasing-2.0.61.jar new file mode 100644 index 0000000000000000000000000000000000000000..8d8e9951e07bab45d56e217f347d430407b70e5e GIT binary patch literal 98823 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 literal 0 HcmV?d00001 diff --git a/extension-iap/lib/web/library_facebook_iap.js b/extension-iap/lib/web/library_facebook_iap.js new file mode 100644 index 0000000..82409af --- /dev/null +++ b/extension-iap/lib/web/library_facebook_iap.js @@ -0,0 +1,169 @@ + +var LibraryFacebookIAP = { + $FBinner: { + // NOTE: Also defined in iap.h + TransactionState : + { + TRANS_STATE_PURCHASING : 0, + TRANS_STATE_PURCHASED : 1, + TRANS_STATE_FAILED : 2, + TRANS_STATE_RESTORED : 3, + TRANS_STATE_UNVERIFIED : 4 + }, + + // NOTE: Also defined in iap.h + BillingResponse : + { + BILLING_RESPONSE_RESULT_OK : 0, + BILLING_RESPONSE_RESULT_USER_CANCELED : 1, + BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE : 3, + BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE : 4, + BILLING_RESPONSE_RESULT_DEVELOPER_ERROR : 5, + BILLING_RESPONSE_RESULT_ERROR : 6, + BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED : 7, + BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED : 8 + }, + + // See Facebook definitions https://developers.facebook.com/docs/payments/reference/errorcodes + FBPaymentResponse : + { + FB_PAYMENT_RESPONSE_USERCANCELED : 1383010, + FB_PAYMENT_RESPONSE_APPINVALIDITEMPARAM : 1383051 + }, + + http_callback: function(xmlhttp, callback, lua_state, products, product_ids, product_count, url_index, url_count) { + if (xmlhttp.readyState == 4) { + if(xmlhttp.status == 200) { + var xmlDoc = document.createElement( 'html' ); + xmlDoc.innerHTML = xmlhttp.responseText; + var elements = xmlDoc.getElementsByTagName('meta'); + + var productInfo = {}; + for (var i=0; i + + + + + + + + + + + + + + + + + + + + diff --git a/extension-iap/manifests/android/extension-adinfo.pro b/extension-iap/manifests/android/extension-adinfo.pro new file mode 100644 index 0000000..30788af --- /dev/null +++ b/extension-iap/manifests/android/extension-adinfo.pro @@ -0,0 +1,4 @@ +-keep class com.defold.iap.** { + public ; +} + diff --git a/extension-iap/src/iap.h b/extension-iap/src/iap.h new file mode 100644 index 0000000..79d8155 --- /dev/null +++ b/extension-iap/src/iap.h @@ -0,0 +1,46 @@ +#if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) + +#ifndef DM_IAP_EXTENSION +#define DM_IAP_EXTENSION + +// NOTE: Also defined in library_facebook_iap.js +// NOTE: Also defined in IapJNI.java + +enum TransactionState +{ + TRANS_STATE_PURCHASING = 0, + TRANS_STATE_PURCHASED = 1, + TRANS_STATE_FAILED = 2, + TRANS_STATE_RESTORED = 3, + TRANS_STATE_UNVERIFIED = 4, +}; + +enum ErrorReason +{ + REASON_UNSPECIFIED = 0, + REASON_USER_CANCELED = 1, +}; + +enum BillingResponse +{ + BILLING_RESPONSE_RESULT_OK = 0, + BILLING_RESPONSE_RESULT_USER_CANCELED = 1, + BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3, + BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4, + BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5, + BILLING_RESPONSE_RESULT_ERROR = 6, + BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7, + BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8, +}; + +enum ProviderId +{ + PROVIDER_ID_GOOGLE = 0, + PROVIDER_ID_AMAZON = 1, + PROVIDER_ID_APPLE = 2, + PROVIDER_ID_FACEBOOK = 3, +}; + +#endif // DM_IAP_EXTENSION + +#endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS diff --git a/extension-iap/src/iap_android.cpp b/extension-iap/src/iap_android.cpp new file mode 100644 index 0000000..7eaf1b6 --- /dev/null +++ b/extension-iap/src/iap_android.cpp @@ -0,0 +1,583 @@ +#if defined(DM_PLATFORM_ANDROID) + +#include +#include +#include +#include +#include "iap.h" +#include "iap_private.h" + +#define LIB_NAME "iap" + +extern struct android_app* g_AndroidApp; + +struct IAP; + +#define CMD_PRODUCT_RESULT (0) +#define CMD_PURCHASE_RESULT (1) + +struct DM_ALIGNED(16) Command +{ + Command() + { + memset(this, 0, sizeof(*this)); + } + uint32_t m_Command; + int32_t m_ResponseCode; + void* m_Data1; +}; + +static dmArray m_commandsQueue; +static dmMutex::HMutex m_mutex; + +static JNIEnv* Attach() +{ + JNIEnv* env; + g_AndroidApp->activity->vm->AttachCurrentThread(&env, NULL); + return env; +} + +static void Detach() +{ + g_AndroidApp->activity->vm->DetachCurrentThread(); +} + + +struct IAP +{ + IAP() + { + memset(this, 0, sizeof(*this)); + m_Callback = LUA_NOREF; + m_Self = LUA_NOREF; + m_Listener.m_Callback = LUA_NOREF; + m_Listener.m_Self = LUA_NOREF; + m_autoFinishTransactions = true; + m_ProviderId = PROVIDER_ID_GOOGLE; + } + int m_InitCount; + int m_Callback; + int m_Self; + bool m_autoFinishTransactions; + int m_ProviderId; + lua_State* m_L; + IAPListener m_Listener; + + jobject m_IAP; + jobject m_IAPJNI; + jmethodID m_List; + jmethodID m_Stop; + jmethodID m_Buy; + jmethodID m_Restore; + jmethodID m_ProcessPendingConsumables; + jmethodID m_FinishTransaction; + int m_Pipefd[2]; +}; + +IAP g_IAP; + +static void add_to_queue(Command cmd) +{ + DM_MUTEX_SCOPED_LOCK(m_mutex); + + if(m_commandsQueue.Full()) + { + m_commandsQueue.OffsetCapacity(1); + } + m_commandsQueue.Push(cmd); +} + +static void VerifyCallback(lua_State* L) +{ + if (g_IAP.m_Callback != LUA_NOREF) { + dmLogError("Unexpected callback set"); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + g_IAP.m_L = 0; + } +} + +int IAP_List(lua_State* L) +{ + int top = lua_gettop(L); + VerifyCallback(L); + + char* buf = IAP_List_CreateBuffer(L); + if( buf == 0 ) + { + assert(top == lua_gettop(L)); + return 0; + } + + luaL_checktype(L, 2, LUA_TFUNCTION); + lua_pushvalue(L, 2); + g_IAP.m_Callback = dmScript::Ref(L, LUA_REGISTRYINDEX); + + dmScript::GetInstance(L); + g_IAP.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + + g_IAP.m_L = dmScript::GetMainThread(L); + + JNIEnv* env = Attach(); + jstring products = env->NewStringUTF(buf); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_List, products, g_IAP.m_IAPJNI); + env->DeleteLocalRef(products); + Detach(); + + free(buf); + assert(top == lua_gettop(L)); + return 0; +} + +int IAP_Buy(lua_State* L) +{ + int top = lua_gettop(L); + + const char* id = luaL_checkstring(L, 1); + + JNIEnv* env = Attach(); + jstring ids = env->NewStringUTF(id); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Buy, ids, g_IAP.m_IAPJNI); + env->DeleteLocalRef(ids); + Detach(); + + assert(top == lua_gettop(L)); + return 0; +} + +int IAP_Finish(lua_State* L) +{ + if(g_IAP.m_autoFinishTransactions) + { + dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored."); + return 0; + } + + int top = lua_gettop(L); + + luaL_checktype(L, 1, LUA_TTABLE); + + lua_getfield(L, -1, "state"); + if (lua_isnumber(L, -1)) + { + if(lua_tointeger(L, -1) != TRANS_STATE_PURCHASED) + { + dmLogError("Invalid transaction state (must be iap.TRANS_STATE_PURCHASED)."); + lua_pop(L, 1); + assert(top == lua_gettop(L)); + return 0; + } + } + lua_pop(L, 1); + + lua_getfield(L, -1, "receipt"); + if (!lua_isstring(L, -1)) { + dmLogError("Transaction error. Invalid transaction data, does not contain 'receipt' key."); + lua_pop(L, 1); + } + else + { + const char * receipt = lua_tostring(L, -1); + lua_pop(L, 1); + + JNIEnv* env = Attach(); + jstring receiptUTF = env->NewStringUTF(receipt); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_FinishTransaction, receiptUTF, g_IAP.m_IAPJNI); + env->DeleteLocalRef(receiptUTF); + Detach(); + } + + assert(top == lua_gettop(L)); + return 0; +} + +int IAP_Restore(lua_State* L) +{ + // TODO: Missing callback here for completion/error + // See iap_ios.mm + + int top = lua_gettop(L); + JNIEnv* env = Attach(); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Restore, g_IAP.m_IAPJNI); + Detach(); + + assert(top == lua_gettop(L)); + + lua_pushboolean(L, 1); + return 1; +} + +int IAP_SetListener(lua_State* L) +{ + IAP* iap = &g_IAP; + luaL_checktype(L, 1, LUA_TFUNCTION); + lua_pushvalue(L, 1); + int cb = dmScript::Ref(L, LUA_REGISTRYINDEX); + + bool had_previous = false; + if (iap->m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Callback); + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Self); + had_previous = true; + } + + iap->m_Listener.m_L = dmScript::GetMainThread(L); + iap->m_Listener.m_Callback = cb; + + dmScript::GetInstance(L); + iap->m_Listener.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + + // On first set listener, trigger process old ones. + if (!had_previous) { + JNIEnv* env = Attach(); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_ProcessPendingConsumables, g_IAP.m_IAPJNI); + Detach(); + } + return 0; +} + +int IAP_GetProviderId(lua_State* L) +{ + lua_pushinteger(L, g_IAP.m_ProviderId); + return 1; +} + +static const luaL_reg IAP_methods[] = +{ + {"list", IAP_List}, + {"buy", IAP_Buy}, + {"finish", IAP_Finish}, + {"restore", IAP_Restore}, + {"set_listener", IAP_SetListener}, + {"get_provider_id", IAP_GetProviderId}, + {0, 0} +}; + + +#ifdef __cplusplus +extern "C" { +#endif + + +JNIEXPORT void JNICALL Java_com_defold_iap_IapJNI_onProductsResult__ILjava_lang_String_2(JNIEnv* env, jobject, jint responseCode, jstring productList) +{ + const char* pl = 0; + if (productList) + { + pl = env->GetStringUTFChars(productList, 0); + } + + Command cmd; + cmd.m_Command = CMD_PRODUCT_RESULT; + cmd.m_ResponseCode = responseCode; + if (pl) + { + cmd.m_Data1 = strdup(pl); + env->ReleaseStringUTFChars(productList, pl); + } + if (write(g_IAP.m_Pipefd[1], &cmd, sizeof(cmd)) != sizeof(cmd)) { + dmLogFatal("Failed to write command"); + } +} + +JNIEXPORT void JNICALL Java_com_defold_iap_IapJNI_onPurchaseResult__ILjava_lang_String_2(JNIEnv* env, jobject, jint responseCode, jstring purchaseData) +{ + const char* pd = 0; + if (purchaseData) + { + pd = env->GetStringUTFChars(purchaseData, 0); + } + + Command cmd; + cmd.m_Command = CMD_PURCHASE_RESULT; + cmd.m_ResponseCode = responseCode; + + if (pd) + { + cmd.m_Data1 = strdup(pd); + env->ReleaseStringUTFChars(purchaseData, pd); + } + if (write(g_IAP.m_Pipefd[1], &cmd, sizeof(cmd)) != sizeof(cmd)) { + dmLogFatal("Failed to write command"); + } +} + +#ifdef __cplusplus +} +#endif + +void HandleProductResult(const Command* cmd) +{ + lua_State* L = g_IAP.m_L; + int top = lua_gettop(L); + + if (g_IAP.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return; + } + + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run IAP callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + + if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { + dmJson::Document doc; + dmJson::Result r = dmJson::Parse((const char*) cmd->m_Data1, &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 product result JSON to Lua; %s", err_str); + 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 { + dmLogError("IAP error %d", cmd->m_ResponseCode); + lua_pushnil(L); + IAP_PushError(L, "failed to fetch product", REASON_UNSPECIFIED); + } + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + + assert(top == lua_gettop(L)); +} + +void HandlePurchaseResult(const Command* cmd) +{ + lua_State* L = g_IAP.m_Listener.m_L; + int top = lua_gettop(L); + + if (g_IAP.m_Listener.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return; + } + + + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run IAP callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + + if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_OK) { + if (cmd->m_Data1 != 0) { + dmJson::Document doc; + dmJson::Result r = dmJson::Parse((const char*) cmd->m_Data1, &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 JSON result to Lua; %s", err_str); + 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 { + dmLogError("IAP error, purchase response was null"); + lua_pushnil(L); + IAP_PushError(L, "purchase response was null", REASON_UNSPECIFIED); + } + } else if (cmd->m_ResponseCode == BILLING_RESPONSE_RESULT_USER_CANCELED) { + lua_pushnil(L); + IAP_PushError(L, "user canceled purchase", REASON_USER_CANCELED); + } else { + dmLogError("IAP error %d", cmd->m_ResponseCode); + lua_pushnil(L); + IAP_PushError(L, "failed to buy product", REASON_UNSPECIFIED); + } + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + + assert(top == lua_gettop(L)); +} + +static void InvokeCallback(Command* cmd) +{ + switch (cmd.m_Command) + { + case CMD_PRODUCT_RESULT: + HandleProductResult(&cmd); + break; + case CMD_PURCHASE_RESULT: + HandlePurchaseResult(&cmd); + break; + + default: + assert(false); + } + + if (cmd.m_Data1) { + free(cmd.m_Data1); + } +} + +static dmExtension::Result InitializeIAP(dmExtension::Params* params) +{ + // TODO: Life-cycle managaemnt is *budget*. No notion of "static initalization" + // Extend extension functionality with per system initalization? + if (g_IAP.m_InitCount == 0) { + + m_mutex = dmMutex::New(); + + g_IAP.m_autoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; + + JNIEnv* env = Attach(); + + jclass activity_class = env->FindClass("android/app/NativeActivity"); + jmethodID get_class_loader = env->GetMethodID(activity_class,"getClassLoader", "()Ljava/lang/ClassLoader;"); + jobject cls = env->CallObjectMethod(g_AndroidApp->activity->clazz, get_class_loader); + jclass class_loader = env->FindClass("java/lang/ClassLoader"); + jmethodID find_class = env->GetMethodID(class_loader, "loadClass", "(Ljava/lang/String;)Ljava/lang/Class;"); + + const char* provider = dmConfigFile::GetString(params->m_ConfigFile, "android.iap_provider", "GooglePlay"); + const char* class_name = "com.defold.iap.IapGooglePlay"; + + g_IAP.m_ProviderId = PROVIDER_ID_GOOGLE; + if (!strcmp(provider, "Amazon")) { + g_IAP.m_ProviderId = PROVIDER_ID_AMAZON; + class_name = "com.defold.iap.IapAmazon"; + } + else if (strcmp(provider, "GooglePlay")) { + dmLogWarning("Unknown IAP provider name [%s], defaulting to GooglePlay", provider); + } + + jstring str_class_name = env->NewStringUTF(class_name); + + jclass iap_class = (jclass)env->CallObjectMethod(cls, find_class, str_class_name); + env->DeleteLocalRef(str_class_name); + + str_class_name = env->NewStringUTF("com.defold.iap.IapJNI"); + jclass iap_jni_class = (jclass)env->CallObjectMethod(cls, find_class, str_class_name); + env->DeleteLocalRef(str_class_name); + + g_IAP.m_List = env->GetMethodID(iap_class, "listItems", "(Ljava/lang/String;Lcom/defold/iap/IListProductsListener;)V"); + g_IAP.m_Buy = env->GetMethodID(iap_class, "buy", "(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_Stop = env->GetMethodID(iap_class, "stop", "()V"); + g_IAP.m_ProcessPendingConsumables = env->GetMethodID(iap_class, "processPendingConsumables", "(Lcom/defold/iap/IPurchaseListener;)V"); + g_IAP.m_FinishTransaction = env->GetMethodID(iap_class, "finishTransaction", "(Ljava/lang/String;Lcom/defold/iap/IPurchaseListener;)V"); + + jmethodID jni_constructor = env->GetMethodID(iap_class, "", "(Landroid/app/Activity;Z)V"); + g_IAP.m_IAP = env->NewGlobalRef(env->NewObject(iap_class, jni_constructor, g_AndroidApp->activity->clazz, g_IAP.m_autoFinishTransactions)); + + jni_constructor = env->GetMethodID(iap_jni_class, "", "()V"); + g_IAP.m_IAPJNI = env->NewGlobalRef(env->NewObject(iap_jni_class, jni_constructor)); + + Detach(); + } + g_IAP.m_InitCount++; + + lua_State*L = params->m_L; + int top = lua_gettop(L); + luaL_register(L, LIB_NAME, IAP_methods); + + IAP_PushConstants(L); + + lua_pop(L, 1); + assert(top == lua_gettop(L)); + + return dmExtension::RESULT_OK; +} + +static dmExtension::Result UpdateIAP(dmExtension::Params* params) +{ + if (m_commandsQueue.Empty()) + { + return dmExtension::RESULT_OK; + } + + DM_MUTEX_SCOPED_LOCK(m_mutex); + + for(uint32_t i = 0; i != m_commandsQueue.Size(); ++i) + { + Command* cmd = &m_commandsQueue[i]; + InvokeCallback(cmd); + m_commandsQueue.EraseSwap(i--); + } + return dmExtension::RESULT_OK; +} + +static dmExtension::Result FinalizeIAP(dmExtension::Params* params) +{ + dmMutex::Delete(m_mutex); + --g_IAP.m_InitCount; + + if (params->m_L == g_IAP.m_Listener.m_L && g_IAP.m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Callback); + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Self); + g_IAP.m_Listener.m_L = 0; + g_IAP.m_Listener.m_Callback = LUA_NOREF; + g_IAP.m_Listener.m_Self = LUA_NOREF; + } + + if (g_IAP.m_InitCount == 0) { + JNIEnv* env = Attach(); + env->CallVoidMethod(g_IAP.m_IAP, g_IAP.m_Stop); + env->DeleteGlobalRef(g_IAP.m_IAP); + env->DeleteGlobalRef(g_IAP.m_IAPJNI); + Detach(); + g_IAP.m_IAP = NULL; + + int result = ALooper_removeFd(g_AndroidApp->looper, g_IAP.m_Pipefd[0]); + if (result != 1) { + dmLogFatal("Could not remove fd from looper: %d", result); + } + + close(g_IAP.m_Pipefd[0]); + close(g_IAP.m_Pipefd[1]); + } + return dmExtension::RESULT_OK; +} + +DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, UpdateIAP, 0, FinalizeIAP) + +#endif //DM_PLATFORM_ANDROID + diff --git a/extension-iap/src/iap_emscripten.cpp b/extension-iap/src/iap_emscripten.cpp new file mode 100644 index 0000000..8d64c01 --- /dev/null +++ b/extension-iap/src/iap_emscripten.cpp @@ -0,0 +1,311 @@ +#if defined(DM_PLATFORM_HTML5) + +#include + +#include +#include + +#include "iap.h" +#include "iap_private.h" + +#define LIB_NAME "iap" + +struct IAP +{ + IAP() + { + memset(this, 0, sizeof(*this)); + m_Callback = LUA_NOREF; + m_Self = LUA_NOREF; + m_Listener.m_Callback = LUA_NOREF; + m_Listener.m_Self = LUA_NOREF; + m_autoFinishTransactions = true; + } + int m_InitCount; + int m_Callback; + int m_Self; + bool m_autoFinishTransactions; + lua_State* m_L; + IAPListener m_Listener; + +} g_IAP; + +typedef void (*OnIAPFBList)(void *L, const char* json); +typedef void (*OnIAPFBListenerCallback)(void *L, const char* json, int error_code); + +extern "C" { + // Implementation in library_facebook_iap.js + void dmIAPFBList(const char* item_ids, OnIAPFBList callback, lua_State* L); + void dmIAPFBBuy(const char* item_id, const char* request_id, OnIAPFBListenerCallback callback, lua_State* L); +} + + +static void VerifyCallback(lua_State* L) +{ + if (g_IAP.m_Callback != LUA_NOREF) { + dmLogError("Unexpected callback set"); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + g_IAP.m_L = 0; + } +} + +void IAPList_Callback(void* Lv, const char* result_json) +{ + lua_State* L = (lua_State*) Lv; + if (g_IAP.m_Callback != LUA_NOREF) { + int top = lua_gettop(L); + int callback = g_IAP.m_Callback; + lua_rawgeti(L, LUA_REGISTRYINDEX, callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run iap facebook callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + if(result_json != 0) + { + dmJson::Document doc; + 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); + 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 + { + dmLogError("Got empty list result."); + lua_pushnil(L); + IAP_PushError(L, "Got empty list result.", REASON_UNSPECIFIED); + } + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + assert(top == lua_gettop(L)); + dmScript::Unref(L, LUA_REGISTRYINDEX, callback); + + g_IAP.m_Callback = LUA_NOREF; + } else { + dmLogError("No callback set"); + } + +} + +int IAP_List(lua_State* L) +{ + int top = lua_gettop(L); + VerifyCallback(L); + + char* buf = IAP_List_CreateBuffer(L); + if( buf == 0 ) + { + assert(top == lua_gettop(L)); + return 0; + } + + luaL_checktype(L, 2, LUA_TFUNCTION); + lua_pushvalue(L, 2); + g_IAP.m_Callback = dmScript::Ref(L, LUA_REGISTRYINDEX); + dmScript::GetInstance(L); + g_IAP.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + g_IAP.m_L = dmScript::GetMainThread(L); + dmIAPFBList(buf, (OnIAPFBList)IAPList_Callback, g_IAP.m_L); + + free(buf); + assert(top == lua_gettop(L)); + return 0; +} + +void IAPListener_Callback(void* Lv, const char* result_json, int error_code) +{ + lua_State* L = g_IAP.m_Listener.m_L; + int top = lua_gettop(L); + if (g_IAP.m_Listener.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return; + } + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run IAP callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + if (result_json) { + dmJson::Document doc; + 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); + 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 { + lua_pushnil(L); + switch(error_code) + { + case BILLING_RESPONSE_RESULT_USER_CANCELED: + IAP_PushError(L, "user canceled purchase", REASON_USER_CANCELED); + break; + + case BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED: + IAP_PushError(L, "product already owned", REASON_UNSPECIFIED); + break; + + default: + dmLogError("IAP error %d", error_code); + IAP_PushError(L, "failed to buy product", REASON_UNSPECIFIED); + break; + } + } + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + assert(top == lua_gettop(L)); +} + + +int IAP_Buy(lua_State* L) +{ + if (g_IAP.m_Listener.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return 0; + } + int top = lua_gettop(L); + const char* id = luaL_checkstring(L, 1); + const char* request_id = 0x0; + + if (top >= 2 && lua_istable(L, 2)) { + luaL_checktype(L, 2, LUA_TTABLE); + lua_pushvalue(L, 2); + lua_getfield(L, -1, "request_id"); + request_id = lua_isnil(L, -1) ? 0x0 : luaL_checkstring(L, -1); + lua_pop(L, 2); + } + + dmIAPFBBuy(id, request_id, (OnIAPFBListenerCallback)IAPListener_Callback, L); + assert(top == lua_gettop(L)); + return 0; +} + +int IAP_SetListener(lua_State* L) +{ + IAP* iap = &g_IAP; + luaL_checktype(L, 1, LUA_TFUNCTION); + lua_pushvalue(L, 1); + int cb = dmScript::Ref(L, LUA_REGISTRYINDEX); + + if (iap->m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Callback); + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Self); + } + iap->m_Listener.m_L = dmScript::GetMainThread(L); + iap->m_Listener.m_Callback = cb; + dmScript::GetInstance(L); + iap->m_Listener.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + + return 0; +} + +int IAP_Finish(lua_State* L) +{ + return 0; +} + +int IAP_Restore(lua_State* L) +{ + lua_pushboolean(L, 0); + return 1; +} + +int IAP_GetProviderId(lua_State* L) +{ + lua_pushinteger(L, PROVIDER_ID_FACEBOOK); + return 1; +} + +static const luaL_reg IAP_methods[] = +{ + {"list", IAP_List}, + {"buy", IAP_Buy}, + {"finish", IAP_Finish}, + {"restore", IAP_Restore}, + {"set_listener", IAP_SetListener}, + {"get_provider_id", IAP_GetProviderId}, + {0, 0} +}; + +dmExtension::Result InitializeIAP(dmExtension::Params* params) +{ + if (g_IAP.m_InitCount == 0) { + g_IAP.m_autoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; + } + g_IAP.m_InitCount++; + lua_State*L = params->m_L; + int top = lua_gettop(L); + luaL_register(L, LIB_NAME, IAP_methods); + + IAP_PushConstants(L); + + lua_pop(L, 1); + assert(top == lua_gettop(L)); + return dmExtension::RESULT_OK; +} + +dmExtension::Result FinalizeIAP(dmExtension::Params* params) +{ + --g_IAP.m_InitCount; + if (params->m_L == g_IAP.m_Listener.m_L && g_IAP.m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Callback); + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Self); + g_IAP.m_Listener.m_L = 0; + g_IAP.m_Listener.m_Callback = LUA_NOREF; + g_IAP.m_Listener.m_Self = LUA_NOREF; + } + return dmExtension::RESULT_OK; +} + +DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, 0, 0, FinalizeIAP) + +#endif // DM_PLATFORM_HTML5 diff --git a/extension-iap/src/iap_ios.mm b/extension-iap/src/iap_ios.mm new file mode 100644 index 0000000..831ca79 --- /dev/null +++ b/extension-iap/src/iap_ios.mm @@ -0,0 +1,806 @@ +#if defined(DM_PLATFORM_IOS) + +#include + +#include "iap.h" +#include "iap_private.h" + +#import +#import +#import + +#define LIB_NAME "iap" + +struct IAP; + +@interface SKPaymentTransactionObserver : NSObject + @property IAP* m_IAP; +@end + +/*# In-app purchases API documentation + * + * Functions and constants for interacting with Apple's In-app purchases + * and Google's In-app billing. + * + * @document + * @name In-app purchases + * @namespace iap + */ + +struct IAP +{ + IAP() + { + m_Callback = LUA_NOREF; + m_Self = LUA_NOREF; + m_InitCount = 0; + m_AutoFinishTransactions = true; + m_PendingTransactions = 0; + } + int m_InitCount; + int m_Callback; + int m_Self; + bool m_AutoFinishTransactions; + NSMutableDictionary* m_PendingTransactions; + IAPListener m_Listener; + SKPaymentTransactionObserver* m_Observer; +}; + +IAP g_IAP; + + + +@interface SKProductsRequestDelegate : NSObject + @property lua_State* m_LuaState; + @property (assign) SKProductsRequest* m_Request; +@end + +@implementation SKProductsRequestDelegate +- (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response{ + + lua_State* L = self.m_LuaState; + if (g_IAP.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return; + } + + NSArray * skProducts = response.products; + int top = lua_gettop(L); + + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run facebook callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + + lua_newtable(L); + for (SKProduct * p in skProducts) { + + lua_pushstring(L, [p.productIdentifier UTF8String]); + lua_newtable(L); + + lua_pushstring(L, "ident"); + lua_pushstring(L, [p.productIdentifier UTF8String]); + lua_rawset(L, -3); + + lua_pushstring(L, "title"); + lua_pushstring(L, [p.localizedTitle UTF8String]); + lua_rawset(L, -3); + + lua_pushstring(L, "description"); + lua_pushstring(L, [p.localizedDescription UTF8String]); + lua_rawset(L, -3); + + lua_pushstring(L, "price"); + lua_pushnumber(L, p.price.floatValue); + lua_rawset(L, -3); + + NSNumberFormatter *formatter = [[[NSNumberFormatter alloc] init] autorelease]; + [formatter setNumberStyle: NSNumberFormatterCurrencyStyle]; + [formatter setLocale: p.priceLocale]; + NSString *price_string = [formatter stringFromNumber: p.price]; + + lua_pushstring(L, "price_string"); + lua_pushstring(L, [price_string UTF8String]); + lua_rawset(L, -3); + + lua_pushstring(L, "currency_code"); + lua_pushstring(L, [[p.priceLocale objectForKey:NSLocaleCurrencyCode] UTF8String]); + lua_rawset(L, -3); + + lua_rawset(L, -3); + } + lua_pushnil(L); + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + + assert(top == lua_gettop(L)); +} + +- (void)request:(SKRequest *)request didFailWithError:(NSError *)error{ + dmLogWarning("SKProductsRequest failed: %s", [error.localizedDescription UTF8String]); + + lua_State* L = self.m_LuaState; + int top = lua_gettop(L); + + if (g_IAP.m_Callback == LUA_NOREF) { + dmLogError("No callback set"); + return; + } + + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run iap callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + + lua_pushnil(L); + IAP_PushError(L, [error.localizedDescription UTF8String], REASON_UNSPECIFIED); + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + + assert(top == lua_gettop(L)); +} + +- (void)requestDidFinish:(SKRequest *)request +{ + [self.m_Request release]; + [self release]; +} + +@end + +static void PushTransaction(lua_State* L, SKPaymentTransaction* transaction) +{ + lua_newtable(L); + + lua_pushstring(L, "ident"); + lua_pushstring(L, [transaction.payment.productIdentifier UTF8String]); + lua_rawset(L, -3); + + lua_pushstring(L, "state"); + lua_pushnumber(L, transaction.transactionState); + lua_rawset(L, -3); + + if (transaction.transactionState == SKPaymentTransactionStatePurchased || transaction.transactionState == SKPaymentTransactionStateRestored) { + lua_pushstring(L, "trans_ident"); + lua_pushstring(L, [transaction.transactionIdentifier UTF8String]); + lua_rawset(L, -3); + } + + if (transaction.transactionState == SKPaymentTransactionStatePurchased) { + lua_pushstring(L, "receipt"); + if (floor(NSFoundationVersionNumber) <= NSFoundationVersionNumber_iOS_6_1) { + lua_pushlstring(L, (const char*) transaction.transactionReceipt.bytes, transaction.transactionReceipt.length); + } else { + NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL]; + NSData *receiptData = [NSData dataWithContentsOfURL:receiptURL]; + lua_pushlstring(L, (const char*) receiptData.bytes, receiptData.length); + } + lua_rawset(L, -3); + } + + NSDateFormatter *dateFormatter = [[[NSDateFormatter alloc] init] autorelease]; + [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZ"]; + lua_pushstring(L, "date"); + lua_pushstring(L, [[dateFormatter stringFromDate: transaction.transactionDate] UTF8String]); + lua_rawset(L, -3); + + if (transaction.transactionState == SKPaymentTransactionStateRestored && transaction.originalTransaction) { + lua_pushstring(L, "original_trans"); + PushTransaction(L, transaction.originalTransaction); + lua_rawset(L, -3); + } +} + +void RunTransactionCallback(lua_State* L, int cb, int self, SKPaymentTransaction* transaction) +{ + int top = lua_gettop(L); + + lua_rawgeti(L, LUA_REGISTRYINDEX, cb); + + // Setup self + lua_rawgeti(L, LUA_REGISTRYINDEX, self); + lua_pushvalue(L, -1); + dmScript::SetInstance(L); + + if (!dmScript::IsInstanceValid(L)) + { + dmLogError("Could not run iap callback because the instance has been deleted."); + lua_pop(L, 2); + assert(top == lua_gettop(L)); + return; + } + + PushTransaction(L, transaction); + + if (transaction.transactionState == SKPaymentTransactionStateFailed) { + if (transaction.error.code == SKErrorPaymentCancelled) { + IAP_PushError(L, [transaction.error.localizedDescription UTF8String], REASON_USER_CANCELED); + } else { + IAP_PushError(L, [transaction.error.localizedDescription UTF8String], REASON_UNSPECIFIED); + } + } else { + lua_pushnil(L); + } + + int ret = lua_pcall(L, 3, 0, 0); + if (ret != 0) { + dmLogError("Error running iap callback"); + lua_pop(L, 1); + } + + assert(top == lua_gettop(L)); +} + +@implementation SKPaymentTransactionObserver + - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions + { + for (SKPaymentTransaction * transaction in transactions) { + + if ((!g_IAP.m_AutoFinishTransactions) && (transaction.transactionState == SKPaymentTransactionStatePurchased)) { + NSData *data = [transaction.transactionIdentifier dataUsingEncoding:NSUTF8StringEncoding]; + uint64_t trans_id_hash = dmHashBuffer64((const char*) [data bytes], [data length]); + [g_IAP.m_PendingTransactions setObject:transaction forKey:[NSNumber numberWithInteger:trans_id_hash] ]; + } + + bool has_listener = false; + if (self.m_IAP->m_Listener.m_Callback != LUA_NOREF) { + const IAPListener& l = self.m_IAP->m_Listener; + RunTransactionCallback(l.m_L, l.m_Callback, l.m_Self, transaction); + has_listener = true; + } + + switch (transaction.transactionState) + { + case SKPaymentTransactionStatePurchasing: + break; + case SKPaymentTransactionStatePurchased: + if (has_listener > 0 && g_IAP.m_AutoFinishTransactions) { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + } + break; + case SKPaymentTransactionStateFailed: + if (has_listener > 0) { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + } + break; + case SKPaymentTransactionStateRestored: + if (has_listener > 0) { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + } + break; + } + } + } +@end + +/*# list in-app products + * + * Get a list of all avaliable iap products. Products are described as a [type:table] + * with the following fields: + * + * `ident` + * : The product identifier. + * + * `title` + * : The product title. + * + * `description` + * : The product description. + * + * `price` + * : The price of the product. + * + * `price_string` + * : The price of the product, as a formatted string (amount and currency symbol). + * + * `currency_code` [icon:ios] [icon:googleplay] [icon:facebook] + * : The currency code. On Google Play, this reflects the merchant's locale, instead of the user's. + * + * [icon:attention] Nested calls, that is calling `iap.list()` from within the callback is + * not supported. Doing so will result in call being ignored with the engine reporting + * "Unexpected callback set". + * + * @name iap.list + * @param ids [type:table] table (array) of identifiers to get products from + * @param callback [type:function(self, products, error)] result callback + * + * `self` + * : [type:object] The current object. + * + * `products` + * : [type:table] Table describing the available iap products. See above for details. + * + * `error` + * : [type:table] a table containing error information. `nil` if there is no error. + * - `error` (the error message) + * + * @examples + * + * ```lua + * local function iap_callback(self, products, error) + * if error == nil then + * for k,p in pairs(products) do + * -- present the product + * print(p.title) + * print(p.description) + * end + * else + * print(error.error) + * end + * end + * + * function init(self) + * iap.list({"my_iap"}, iap_callback) + * end + * ``` + */ +int IAP_List(lua_State* L) +{ + int top = lua_gettop(L); + if (g_IAP.m_Callback != LUA_NOREF) { + dmLogError("Unexpected callback set"); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Callback); + dmScript::Unref(L, LUA_REGISTRYINDEX, g_IAP.m_Self); + g_IAP.m_Callback = LUA_NOREF; + g_IAP.m_Self = LUA_NOREF; + } + + NSCountedSet* product_identifiers = [[[NSCountedSet alloc] init] autorelease]; + + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + const char* p = luaL_checkstring(L, -1); + [product_identifiers addObject: [NSString stringWithUTF8String: p]]; + lua_pop(L, 1); + } + + luaL_checktype(L, 2, LUA_TFUNCTION); + lua_pushvalue(L, 2); + g_IAP.m_Callback = dmScript::Ref(L, LUA_REGISTRYINDEX); + + dmScript::GetInstance(L); + g_IAP.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + + SKProductsRequest* products_request = [[SKProductsRequest alloc] initWithProductIdentifiers: product_identifiers]; + SKProductsRequestDelegate* delegate = [SKProductsRequestDelegate alloc]; + delegate.m_LuaState = dmScript::GetMainThread(L); + delegate.m_Request = products_request; + products_request.delegate = delegate; + [products_request start]; + + assert(top == lua_gettop(L)); + return 0; +} + +/*# buy product + * + * Perform a product purchase. + * + * [icon:attention] Calling `iap.finish()` is required on a successful transaction if + * `auto_finish_transactions` is disabled in project settings. + * + * @name iap.buy + * @param id [type:string] product to buy + * @param [options] [type:table] optional parameters as properties. The following parameters can be set: + * + * - `request_id` ([icon:facebook] Facebook only. Optional custom unique request id to + * set for this transaction. The id becomes attached to the payment within the Graph API.) + * + * @examples + * + * ```lua + * local function iap_listener(self, transaction, error) + * if error == nil then + * -- purchase is successful. + * print(transaction.date) + * -- required if auto finish transactions is disabled in project settings + * if (transaction.state == iap.TRANS_STATE_PURCHASED) then + * -- do server-side verification of purchase here.. + * iap.finish(transaction) + * end + * else + * print(error.error, error.reason) + * end + * end + * + * function init(self) + * iap.set_listener(iap_listener) + * iap.buy("my_iap") + * end + * ``` + */ +int IAP_Buy(lua_State* L) +{ + int top = lua_gettop(L); + + const char* id = luaL_checkstring(L, 1); + SKMutablePayment* payment = [[SKMutablePayment alloc] init]; + payment.productIdentifier = [NSString stringWithUTF8String: id]; + payment.quantity = 1; + + [[SKPaymentQueue defaultQueue] addPayment:payment]; + [payment release]; + + assert(top == lua_gettop(L)); + return 0; +} + +/*# finish buying product + * + * Explicitly finish a product transaction. + * + * [icon:attention] Calling iap.finish is required on a successful transaction + * if `auto_finish_transactions` is disabled in project settings. Calling this function + * with `auto_finish_transactions` set will be ignored and a warning is printed. + * The `transaction.state` field must equal `iap.TRANS_STATE_PURCHASED`. + * + * @name iap.finish + * @param transaction [type:table] transaction table parameter as supplied in listener callback + * + */ +int IAP_Finish(lua_State* L) +{ + if(g_IAP.m_AutoFinishTransactions) + { + dmLogWarning("Calling iap.finish when autofinish transactions is enabled. Ignored."); + return 0; + } + + int top = lua_gettop(L); + + luaL_checktype(L, 1, LUA_TTABLE); + + lua_getfield(L, -1, "state"); + if (lua_isnumber(L, -1)) + { + if(lua_tointeger(L, -1) != SKPaymentTransactionStatePurchased) + { + dmLogError("Transaction error. Invalid transaction state for transaction finish (must be iap.TRANS_STATE_PURCHASED)."); + lua_pop(L, 1); + assert(top == lua_gettop(L)); + return 0; + } + } + lua_pop(L, 1); + + lua_getfield(L, -1, "trans_ident"); + if (!lua_isstring(L, -1)) { + dmLogError("Transaction error. Invalid transaction data for transaction finish, does not contain 'trans_ident' key."); + lua_pop(L, 1); + } + else + { + const char *str = lua_tostring(L, -1); + uint64_t trans_ident_hash = dmHashBuffer64(str, strlen(str)); + lua_pop(L, 1); + SKPaymentTransaction * transaction = [g_IAP.m_PendingTransactions objectForKey:[NSNumber numberWithInteger:trans_ident_hash]]; + if(transaction == 0x0) { + dmLogError("Transaction error. Invalid trans_ident value for transaction finish."); + } else { + [[SKPaymentQueue defaultQueue] finishTransaction:transaction]; + [g_IAP.m_PendingTransactions removeObjectForKey:[NSNumber numberWithInteger:trans_ident_hash]]; + } + } + + assert(top == lua_gettop(L)); + return 0; +} + +/*# restore products (non-consumable) + * + * Restore previously purchased products. + * + * @name iap.restore + * @return success [type:boolean] `true` if current store supports handling + * restored transactions, otherwise `false`. + */ +int IAP_Restore(lua_State* L) +{ + // TODO: Missing callback here for completion/error + // See callback under "Handling Restored Transactions" + // https://developer.apple.com/library/ios/documentation/StoreKit/Reference/SKPaymentTransactionObserver_Protocol/Reference/Reference.html + int top = lua_gettop(L); + [[SKPaymentQueue defaultQueue] restoreCompletedTransactions]; + assert(top == lua_gettop(L)); + lua_pushboolean(L, 1); + return 1; +} + +/*# set purchase transaction listener + * + * Set the callback function to receive purchase transaction events. Transactions are + * described as a [type:table] with the following fields: + * + * `ident` + * : The product identifier. + * + * `state` + * : The transaction state. See `iap.TRANS_STATE_*`. + * + * `date` + * : The date and time for the transaction. + * + * `trans_ident` + * : The transaction identifier. This field is only set when `state` is TRANS_STATE_RESTORED, + * TRANS_STATE_UNVERIFIED or TRANS_STATE_PURCHASED. + * + * `receipt` + * : The transaction receipt. This field is only set when `state` is TRANS_STATE_PURCHASED + * or TRANS_STATE_UNVERIFIED. + * + * `original_trans` [icon:apple] + * : Apple only. The original transaction. This field is only set when `state` is + * TRANS_STATE_RESTORED. + * + * `signature` [icon:googleplay] + * : Google Play only. A string containing the signature of the purchase data that was signed + * with the private key of the developer. + * + * `request_id` [icon:facebook] + * : Facebook only. This field is set to the optional custom unique request id `request_id` + * if set in the `iap.buy()` call parameters. + * + * `user_id` [icon:amazon] + * : Amazon Pay only. The user ID. + * + * `is_sandbox_mode` [icon:amazon] + * : Amazon Pay only. If `true`, the SDK is running in Sandbox mode. This only allows + * interactions with the Amazon AppTester. Use this mode only for testing locally. + * + * `cancel_date` [icon:amazon] + * : Amazon Pay only. The cancel date for the purchase. This field is only set if the + * purchase is canceled. + * + * `canceled` [icon:amazon] + * : Amazon Pay only. Is set to `true` if the receipt was canceled or has expired; + * otherwise `false`. + * + * @name iap.set_listener + * @param listener [type:function(self, transaction, error)] listener callback function. + * Pass an empty function if you no longer wish to receive callbacks. + * + * `self` + * : [type:object] The current object. + * + * `transaction` + * : [type:table] a table describing the transaction. See above for details. + * + * `error` + * : [type:table] a table containing error information. `nil` if there is no error. + * - `error` (the error message) + * - `reason` (the reason for the error, see `iap.REASON_*`) + * + */ +int IAP_SetListener(lua_State* L) +{ + IAP* iap = &g_IAP; + luaL_checktype(L, 1, LUA_TFUNCTION); + lua_pushvalue(L, 1); + int cb = dmScript::Ref(L, LUA_REGISTRYINDEX); + + if (iap->m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Callback); + dmScript::Unref(iap->m_Listener.m_L, LUA_REGISTRYINDEX, iap->m_Listener.m_Self); + } + + iap->m_Listener.m_L = dmScript::GetMainThread(L); + iap->m_Listener.m_Callback = cb; + + dmScript::GetInstance(L); + iap->m_Listener.m_Self = dmScript::Ref(L, LUA_REGISTRYINDEX); + + if (g_IAP.m_Observer == 0) { + SKPaymentTransactionObserver* observer = [[SKPaymentTransactionObserver alloc] init]; + observer.m_IAP = &g_IAP; + // NOTE: We add the listener *after* a lua listener is set + // The payment queue is persistent and "old" transaction might be processed + // from previous session. We call "finishTransaction" when appropriate + // for all transaction and we must ensure that the result is delivered to lua. + [[SKPaymentQueue defaultQueue] addTransactionObserver: observer]; + g_IAP.m_Observer = observer; + } + + return 0; +} + +/*# get current provider id + * + * @name iap.get_provider_id + * @return id [type:constant] provider id. + * + * - `iap.PROVIDER_ID_GOOGLE` + * - `iap.PROVIDER_ID_AMAZON` + * - `iap.PROVIDER_ID_APPLE` + * - `iap.PROVIDER_ID_FACEBOOK` + * + */ +int IAP_GetProviderId(lua_State* L) +{ + lua_pushinteger(L, PROVIDER_ID_APPLE); + return 1; +} + +static const luaL_reg IAP_methods[] = +{ + {"list", IAP_List}, + {"buy", IAP_Buy}, + {"finish", IAP_Finish}, + {"restore", IAP_Restore}, + {"set_listener", IAP_SetListener}, + {"get_provider_id", IAP_GetProviderId}, + {0, 0} +}; + +/*# transaction purchasing state + * + * This is an intermediate mode followed by TRANS_STATE_PURCHASED. + * Store provider support dependent. + * + * @name iap.TRANS_STATE_PURCHASING + * @variable + */ + +/*# transaction purchased state + * + * @name iap.TRANS_STATE_PURCHASED + * @variable + */ + +/*# transaction unverified state, requires verification of purchase + * + * @name iap.TRANS_STATE_UNVERIFIED + * @variable + */ + +/*# transaction failed state + * + * @name iap.TRANS_STATE_FAILED + * @variable + */ + +/*# transaction restored state + * + * This is only available on store providers supporting restoring purchases. + * + * @name iap.TRANS_STATE_RESTORED + * @variable + */ + +/*# unspecified error reason + * + * @name iap.REASON_UNSPECIFIED + * @variable + */ + +/*# user canceled reason + * + * @name iap.REASON_USER_CANCELED + * @variable + */ + + +/*# iap provider id for Google + * + * @name iap.PROVIDER_ID_GOOGLE + * @variable + */ + +/*# provider id for Amazon + * + * @name iap.PROVIDER_ID_AMAZON + * @variable + */ + +/*# provider id for Apple + * + * @name iap.PROVIDER_ID_APPLE + * @variable + */ + +/*# provider id for Facebook + * + * @name iap.PROVIDER_ID_FACEBOOK + * @variable + */ + +dmExtension::Result InitializeIAP(dmExtension::Params* params) +{ + // TODO: Life-cycle managaemnt is *budget*. No notion of "static initalization" + // Extend extension functionality with per system initalization? + if (g_IAP.m_InitCount == 0) { + g_IAP.m_AutoFinishTransactions = dmConfigFile::GetInt(params->m_ConfigFile, "iap.auto_finish_transactions", 1) == 1; + g_IAP.m_PendingTransactions = [[NSMutableDictionary alloc]initWithCapacity:2]; + } + g_IAP.m_InitCount++; + + + lua_State*L = params->m_L; + int top = lua_gettop(L); + luaL_register(L, LIB_NAME, IAP_methods); + + // ensure ios payment constants values corresponds to iap constants. + assert(TRANS_STATE_PURCHASING == SKPaymentTransactionStatePurchasing); + assert(TRANS_STATE_PURCHASED == SKPaymentTransactionStatePurchased); + assert(TRANS_STATE_FAILED == SKPaymentTransactionStateFailed); + assert(TRANS_STATE_RESTORED == SKPaymentTransactionStateRestored); + + IAP_PushConstants(L); + + lua_pop(L, 1); + assert(top == lua_gettop(L)); + + return dmExtension::RESULT_OK; +} + +dmExtension::Result FinalizeIAP(dmExtension::Params* params) +{ + --g_IAP.m_InitCount; + + // TODO: Should we support one listener per lua-state? + // Or just use a single lua-state...? + if (params->m_L == g_IAP.m_Listener.m_L && g_IAP.m_Listener.m_Callback != LUA_NOREF) { + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Callback); + dmScript::Unref(g_IAP.m_Listener.m_L, LUA_REGISTRYINDEX, g_IAP.m_Listener.m_Self); + g_IAP.m_Listener.m_L = 0; + g_IAP.m_Listener.m_Callback = LUA_NOREF; + g_IAP.m_Listener.m_Self = LUA_NOREF; + } + + if (g_IAP.m_InitCount == 0) { + if (g_IAP.m_PendingTransactions) { + [g_IAP.m_PendingTransactions release]; + g_IAP.m_PendingTransactions = 0; + } + + if (g_IAP.m_Observer) { + [[SKPaymentQueue defaultQueue] removeTransactionObserver: g_IAP.m_Observer]; + [g_IAP.m_Observer release]; + g_IAP.m_Observer = 0; + } + } + return dmExtension::RESULT_OK; +} + + +DM_DECLARE_EXTENSION(IAPExt, "IAP", 0, 0, InitializeIAP, 0, 0, FinalizeIAP) + +#endif // DM_PLATFORM_IOS diff --git a/extension-iap/src/iap_null.cpp b/extension-iap/src/iap_null.cpp new file mode 100644 index 0000000..8ccdb65 --- /dev/null +++ b/extension-iap/src/iap_null.cpp @@ -0,0 +1,9 @@ +#if !defined(DM_PLATFORM_HTML5) && !defined(DM_PLATFORM_ANDROID) && !defined(DM_PLATFORM_IOS) + +extern "C" void IAPExt() +{ + +} + +#endif // !DM_PLATFORM_HTML5 && !DM_PLATFORM_ANDROID && !DM_PLATFORM_IOS + diff --git a/extension-iap/src/iap_private.cpp b/extension-iap/src/iap_private.cpp new file mode 100644 index 0000000..bb6636d --- /dev/null +++ b/extension-iap/src/iap_private.cpp @@ -0,0 +1,99 @@ +#if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) + +#include + +#include "iap.h" +#include "iap_private.h" +#include +#include + +// Creates a comma separated string, given a table where all values are strings (or numbers) +// Returns a malloc'ed string, which the caller must free +char* IAP_List_CreateBuffer(lua_State* L) +{ + int top = lua_gettop(L); + + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnil(L); + int length = 0; + while (lua_next(L, 1) != 0) { + if (length > 0) { + ++length; + } + const char* p = lua_tostring(L, -1); + if(!p) + { + luaL_error(L, "IAP: Failed to get value (string) from table"); + } + length += strlen(p); + lua_pop(L, 1); + } + + char* buf = (char*)malloc(length+1); + if( buf == 0 ) + { + dmLogError("Could not allocate buffer of size %d", length+1); + assert(top == lua_gettop(L)); + return 0; + } + buf[0] = '\0'; + + int i = 0; + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + if (i > 0) { + strncat(buf, ",", length+1); + } + const char* p = lua_tostring(L, -1); + if(!p) + { + luaL_error(L, "IAP: Failed to get value (string) from table"); + } + strncat(buf, p, length+1); + lua_pop(L, 1); + ++i; + } + + assert(top == lua_gettop(L)); + return buf; +} + +void IAP_PushError(lua_State* L, const char* error, int reason) +{ + if (error != 0) { + lua_newtable(L); + lua_pushstring(L, "error"); + lua_pushstring(L, error); + lua_rawset(L, -3); + lua_pushstring(L, "reason"); + lua_pushnumber(L, reason); + lua_rawset(L, -3); + } else { + lua_pushnil(L); + } +} + +void IAP_PushConstants(lua_State* L) +{ + #define SETCONSTANT(name) \ + lua_pushnumber(L, (lua_Number) name); \ + lua_setfield(L, -2, #name);\ + + SETCONSTANT(TRANS_STATE_PURCHASING) + SETCONSTANT(TRANS_STATE_PURCHASED) + SETCONSTANT(TRANS_STATE_FAILED) + SETCONSTANT(TRANS_STATE_RESTORED) + SETCONSTANT(TRANS_STATE_UNVERIFIED) + + SETCONSTANT(REASON_UNSPECIFIED) + SETCONSTANT(REASON_USER_CANCELED) + + SETCONSTANT(PROVIDER_ID_GOOGLE) + SETCONSTANT(PROVIDER_ID_AMAZON) + SETCONSTANT(PROVIDER_ID_APPLE) + SETCONSTANT(PROVIDER_ID_FACEBOOK) + + #undef SETCONSTANT +} + +#endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS diff --git a/extension-iap/src/iap_private.h b/extension-iap/src/iap_private.h new file mode 100644 index 0000000..811ae15 --- /dev/null +++ b/extension-iap/src/iap_private.h @@ -0,0 +1,28 @@ +#if defined(DM_PLATFORM_HTML5) || defined(DM_PLATFORM_ANDROID) || defined(DM_PLATFORM_IOS) + +#ifndef IAP_PRIVATE_H +#define IAP_PRIVATE_H + +#include + +struct IAPListener +{ + IAPListener() + { + m_L = 0; + m_Callback = LUA_NOREF; + m_Self = LUA_NOREF; + } + lua_State* m_L; + int m_Callback; + int m_Self; +}; + + +char* IAP_List_CreateBuffer(lua_State* L); +void IAP_PushError(lua_State* L, const char* error, int reason); +void IAP_PushConstants(lua_State* L); + +#endif + +#endif // DM_PLATFORM_HTML5 || DM_PLATFORM_ANDROID || DM_PLATFORM_IOS diff --git a/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java b/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java new file mode 100644 index 0000000..0d728e5 --- /dev/null +++ b/extension-iap/src/java/com/android/vending/billing/IInAppBillingService.java @@ -0,0 +1,501 @@ +/* + * This file is auto-generated. DO NOT MODIFY. + * Original file: /Users/chmu/android_workspace/TestBilling/src/com/android/vending/billing/IInAppBillingService.aidl + */ +package com.android.vending.billing; +/** + * InAppBillingService is the service that provides in-app billing version 3 and beyond. + * This service provides the following features: + * 1. Provides a new API to get details of in-app items published for the app including + * price, type, title and description. + * 2. The purchase flow is synchronous and purchase information is available immediately + * after it completes. + * 3. Purchase information of in-app purchases is maintained within the Google Play system + * till the purchase is consumed. + * 4. An API to consume a purchase of an inapp item. All purchases of one-time + * in-app items are consumable and thereafter can be purchased again. + * 5. An API to get current purchases of the user immediately. This will not contain any + * consumed purchases. + * + * All calls will give a response code with the following possible values + * RESULT_OK = 0 - success + * RESULT_USER_CANCELED = 1 - user pressed back or canceled a dialog + * RESULT_BILLING_UNAVAILABLE = 3 - this billing API version is not supported for the type requested + * RESULT_ITEM_UNAVAILABLE = 4 - requested SKU is not available for purchase + * RESULT_DEVELOPER_ERROR = 5 - invalid arguments provided to the API + * RESULT_ERROR = 6 - Fatal error during the API action + * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned + * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned + */ +public interface IInAppBillingService extends android.os.IInterface +{ +/** Local-side IPC implementation stub class. */ +public static abstract class Stub extends android.os.Binder implements com.android.vending.billing.IInAppBillingService +{ +private static final java.lang.String DESCRIPTOR = "com.android.vending.billing.IInAppBillingService"; +/** Construct the stub at attach it to the interface. */ +public Stub() +{ +this.attachInterface(this, DESCRIPTOR); +} +/** + * Cast an IBinder object into an com.android.vending.billing.IInAppBillingService interface, + * generating a proxy if needed. + */ +public static com.android.vending.billing.IInAppBillingService asInterface(android.os.IBinder obj) +{ +if ((obj==null)) { +return null; +} +android.os.IInterface iin = obj.queryLocalInterface(DESCRIPTOR); +if (((iin!=null)&&(iin instanceof com.android.vending.billing.IInAppBillingService))) { +return ((com.android.vending.billing.IInAppBillingService)iin); +} +return new com.android.vending.billing.IInAppBillingService.Stub.Proxy(obj); +} +@Override public android.os.IBinder asBinder() +{ +return this; +} +@Override public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException +{ +switch (code) +{ +case INTERFACE_TRANSACTION: +{ +reply.writeString(DESCRIPTOR); +return true; +} +case TRANSACTION_isBillingSupported: +{ +data.enforceInterface(DESCRIPTOR); +int _arg0; +_arg0 = data.readInt(); +java.lang.String _arg1; +_arg1 = data.readString(); +java.lang.String _arg2; +_arg2 = data.readString(); +int _result = this.isBillingSupported(_arg0, _arg1, _arg2); +reply.writeNoException(); +reply.writeInt(_result); +return true; +} +case TRANSACTION_getSkuDetails: +{ +data.enforceInterface(DESCRIPTOR); +int _arg0; +_arg0 = data.readInt(); +java.lang.String _arg1; +_arg1 = data.readString(); +java.lang.String _arg2; +_arg2 = data.readString(); +android.os.Bundle _arg3; +if ((0!=data.readInt())) { +_arg3 = android.os.Bundle.CREATOR.createFromParcel(data); +} +else { +_arg3 = null; +} +android.os.Bundle _result = this.getSkuDetails(_arg0, _arg1, _arg2, _arg3); +reply.writeNoException(); +if ((_result!=null)) { +reply.writeInt(1); +_result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); +} +else { +reply.writeInt(0); +} +return true; +} +case TRANSACTION_getBuyIntent: +{ +data.enforceInterface(DESCRIPTOR); +int _arg0; +_arg0 = data.readInt(); +java.lang.String _arg1; +_arg1 = data.readString(); +java.lang.String _arg2; +_arg2 = data.readString(); +java.lang.String _arg3; +_arg3 = data.readString(); +java.lang.String _arg4; +_arg4 = data.readString(); +android.os.Bundle _result = this.getBuyIntent(_arg0, _arg1, _arg2, _arg3, _arg4); +reply.writeNoException(); +if ((_result!=null)) { +reply.writeInt(1); +_result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); +} +else { +reply.writeInt(0); +} +return true; +} +case TRANSACTION_getPurchases: +{ +data.enforceInterface(DESCRIPTOR); +int _arg0; +_arg0 = data.readInt(); +java.lang.String _arg1; +_arg1 = data.readString(); +java.lang.String _arg2; +_arg2 = data.readString(); +java.lang.String _arg3; +_arg3 = data.readString(); +android.os.Bundle _result = this.getPurchases(_arg0, _arg1, _arg2, _arg3); +reply.writeNoException(); +if ((_result!=null)) { +reply.writeInt(1); +_result.writeToParcel(reply, android.os.Parcelable.PARCELABLE_WRITE_RETURN_VALUE); +} +else { +reply.writeInt(0); +} +return true; +} +case TRANSACTION_consumePurchase: +{ +data.enforceInterface(DESCRIPTOR); +int _arg0; +_arg0 = data.readInt(); +java.lang.String _arg1; +_arg1 = data.readString(); +java.lang.String _arg2; +_arg2 = data.readString(); +int _result = this.consumePurchase(_arg0, _arg1, _arg2); +reply.writeNoException(); +reply.writeInt(_result); +return true; +} +} +return super.onTransact(code, data, reply, flags); +} +private static class Proxy implements com.android.vending.billing.IInAppBillingService +{ +private android.os.IBinder mRemote; +Proxy(android.os.IBinder remote) +{ +mRemote = remote; +} +@Override public android.os.IBinder asBinder() +{ +return mRemote; +} +public java.lang.String getInterfaceDescriptor() +{ +return DESCRIPTOR; +} +/** + * Checks support for the requested billing API version, package and in-app type. + * Minimum API version supported by this interface is 3. + * @param apiVersion the billing version which the app is using + * @param packageName the package name of the calling app + * @param type type of the in-app item being purchased "inapp" for one-time purchases + * and "subs" for subscription. + * @return RESULT_OK(0) on success, corresponding result code on failures + */ +@Override public int isBillingSupported(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException +{ +android.os.Parcel _data = android.os.Parcel.obtain(); +android.os.Parcel _reply = android.os.Parcel.obtain(); +int _result; +try { +_data.writeInterfaceToken(DESCRIPTOR); +_data.writeInt(apiVersion); +_data.writeString(packageName); +_data.writeString(type); +mRemote.transact(Stub.TRANSACTION_isBillingSupported, _data, _reply, 0); +_reply.readException(); +_result = _reply.readInt(); +} +finally { +_reply.recycle(); +_data.recycle(); +} +return _result; +} +/** + * Provides details of a list of SKUs + * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle + * with a list JSON strings containing the productId, price, title and description. + * This API can be called with a maximum of 20 SKUs. + * @param apiVersion billing API version that the Third-party is using + * @param packageName the package name of the calling app + * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "DETAILS_LIST" with a StringArrayList containing purchase information + * in JSON format similar to: + * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00", + * "title : "Example Title", "description" : "This is an example description" }' + */ +@Override public android.os.Bundle getSkuDetails(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle skusBundle) throws android.os.RemoteException +{ +android.os.Parcel _data = android.os.Parcel.obtain(); +android.os.Parcel _reply = android.os.Parcel.obtain(); +android.os.Bundle _result; +try { +_data.writeInterfaceToken(DESCRIPTOR); +_data.writeInt(apiVersion); +_data.writeString(packageName); +_data.writeString(type); +if ((skusBundle!=null)) { +_data.writeInt(1); +skusBundle.writeToParcel(_data, 0); +} +else { +_data.writeInt(0); +} +mRemote.transact(Stub.TRANSACTION_getSkuDetails, _data, _reply, 0); +_reply.readException(); +if ((0!=_reply.readInt())) { +_result = android.os.Bundle.CREATOR.createFromParcel(_reply); +} +else { +_result = null; +} +} +finally { +_reply.recycle(); +_data.recycle(); +} +return _result; +} +/** + * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, + * the type, a unique purchase token and an optional developer payload. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type the type of the in-app item ("inapp" for one-time purchases + * and "subs" for subscription). + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + * TODO: change this to app-specific keys. + */ +@Override public android.os.Bundle getBuyIntent(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException +{ +android.os.Parcel _data = android.os.Parcel.obtain(); +android.os.Parcel _reply = android.os.Parcel.obtain(); +android.os.Bundle _result; +try { +_data.writeInterfaceToken(DESCRIPTOR); +_data.writeInt(apiVersion); +_data.writeString(packageName); +_data.writeString(sku); +_data.writeString(type); +_data.writeString(developerPayload); +mRemote.transact(Stub.TRANSACTION_getBuyIntent, _data, _reply, 0); +_reply.readException(); +if ((0!=_reply.readInt())) { +_result = android.os.Bundle.CREATOR.createFromParcel(_reply); +} +else { +_result = null; +} +} +finally { +_reply.recycle(); +_data.recycle(); +} +return _result; +} +/** + * Returns the current SKUs owned by the user of the type and package name specified along with + * purchase information and a signature of the data to be validated. + * This will return all SKUs that have been purchased in V3 and managed items purchased using + * V1 and V2 that have not been consumed. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param type the type of the in-app items being requested + * ("inapp" for one-time purchases and "subs" for subscription). + * @param continuationToken to be set as null for the first call, if the number of owned + * skus are too many, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ +@Override public android.os.Bundle getPurchases(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken) throws android.os.RemoteException +{ +android.os.Parcel _data = android.os.Parcel.obtain(); +android.os.Parcel _reply = android.os.Parcel.obtain(); +android.os.Bundle _result; +try { +_data.writeInterfaceToken(DESCRIPTOR); +_data.writeInt(apiVersion); +_data.writeString(packageName); +_data.writeString(type); +_data.writeString(continuationToken); +mRemote.transact(Stub.TRANSACTION_getPurchases, _data, _reply, 0); +_reply.readException(); +if ((0!=_reply.readInt())) { +_result = android.os.Bundle.CREATOR.createFromParcel(_reply); +} +else { +_result = null; +} +} +finally { +_reply.recycle(); +_data.recycle(); +} +return _result; +} +/** + * Consume the last purchase of the given SKU. This will result in this item being removed + * from all subsequent responses to getPurchases() and allow re-purchase of this item. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param purchaseToken token in the purchase information JSON that identifies the purchase + * to be consumed + * @return 0 if consumption succeeded. Appropriate error values for failures. + */ +@Override public int consumePurchase(int apiVersion, java.lang.String packageName, java.lang.String purchaseToken) throws android.os.RemoteException +{ +android.os.Parcel _data = android.os.Parcel.obtain(); +android.os.Parcel _reply = android.os.Parcel.obtain(); +int _result; +try { +_data.writeInterfaceToken(DESCRIPTOR); +_data.writeInt(apiVersion); +_data.writeString(packageName); +_data.writeString(purchaseToken); +mRemote.transact(Stub.TRANSACTION_consumePurchase, _data, _reply, 0); +_reply.readException(); +_result = _reply.readInt(); +} +finally { +_reply.recycle(); +_data.recycle(); +} +return _result; +} +} +static final int TRANSACTION_isBillingSupported = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0); +static final int TRANSACTION_getSkuDetails = (android.os.IBinder.FIRST_CALL_TRANSACTION + 1); +static final int TRANSACTION_getBuyIntent = (android.os.IBinder.FIRST_CALL_TRANSACTION + 2); +static final int TRANSACTION_getPurchases = (android.os.IBinder.FIRST_CALL_TRANSACTION + 3); +static final int TRANSACTION_consumePurchase = (android.os.IBinder.FIRST_CALL_TRANSACTION + 4); +} +/** + * Checks support for the requested billing API version, package and in-app type. + * Minimum API version supported by this interface is 3. + * @param apiVersion the billing version which the app is using + * @param packageName the package name of the calling app + * @param type type of the in-app item being purchased "inapp" for one-time purchases + * and "subs" for subscription. + * @return RESULT_OK(0) on success, corresponding result code on failures + */ +public int isBillingSupported(int apiVersion, java.lang.String packageName, java.lang.String type) throws android.os.RemoteException; +/** + * Provides details of a list of SKUs + * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle + * with a list JSON strings containing the productId, price, title and description. + * This API can be called with a maximum of 20 SKUs. + * @param apiVersion billing API version that the Third-party is using + * @param packageName the package name of the calling app + * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "DETAILS_LIST" with a StringArrayList containing purchase information + * in JSON format similar to: + * '{ "productId" : "exampleSku", "type" : "inapp", "price" : "$5.00", + * "title : "Example Title", "description" : "This is an example description" }' + */ +public android.os.Bundle getSkuDetails(int apiVersion, java.lang.String packageName, java.lang.String type, android.os.Bundle skusBundle) throws android.os.RemoteException; +/** + * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, + * the type, a unique purchase token and an optional developer payload. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param sku the SKU of the in-app item as published in the developer console + * @param type the type of the in-app item ("inapp" for one-time purchases + * and "subs" for subscription). + * @param developerPayload optional argument to be sent back with the purchase information + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "BUY_INTENT" - PendingIntent to start the purchase flow + * + * The Pending intent should be launched with startIntentSenderForResult. When purchase flow + * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. + * If the purchase is successful, the result data will contain the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "INAPP_PURCHASE_DATA" - String in JSON format similar to + * '{"orderId":"12999763169054705758.1371079406387615", + * "packageName":"com.example.app", + * "productId":"exampleSku", + * "purchaseTime":1345678900000, + * "purchaseToken" : "122333444455555", + * "developerPayload":"example developer payload" }' + * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that + * was signed with the private key of the developer + * TODO: change this to app-specific keys. + */ +public android.os.Bundle getBuyIntent(int apiVersion, java.lang.String packageName, java.lang.String sku, java.lang.String type, java.lang.String developerPayload) throws android.os.RemoteException; +/** + * Returns the current SKUs owned by the user of the type and package name specified along with + * purchase information and a signature of the data to be validated. + * This will return all SKUs that have been purchased in V3 and managed items purchased using + * V1 and V2 that have not been consumed. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param type the type of the in-app items being requested + * ("inapp" for one-time purchases and "subs" for subscription). + * @param continuationToken to be set as null for the first call, if the number of owned + * skus are too many, a continuationToken is returned in the response bundle. + * This method can be called again with the continuation token to get the next set of + * owned skus. + * @return Bundle containing the following key-value pairs + * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, other response codes on + * failure as listed above. + * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs + * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information + * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures + * of the purchase information + * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the + * next set of in-app purchases. Only set if the + * user has more owned skus than the current list. + */ +public android.os.Bundle getPurchases(int apiVersion, java.lang.String packageName, java.lang.String type, java.lang.String continuationToken) throws android.os.RemoteException; +/** + * Consume the last purchase of the given SKU. This will result in this item being removed + * from all subsequent responses to getPurchases() and allow re-purchase of this item. + * @param apiVersion billing API version that the app is using + * @param packageName package name of the calling app + * @param purchaseToken token in the purchase information JSON that identifies the purchase + * to be consumed + * @return 0 if consumption succeeded. Appropriate error values for failures. + */ +public int consumePurchase(int apiVersion, java.lang.String packageName, java.lang.String purchaseToken) throws android.os.RemoteException; +} diff --git a/extension-iap/src/java/com/defold/iap/IListProductsListener.java b/extension-iap/src/java/com/defold/iap/IListProductsListener.java new file mode 100644 index 0000000..1a93be4 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IListProductsListener.java @@ -0,0 +1,5 @@ +package com.defold.iap; + +public interface IListProductsListener { + public void onProductsResult(int resultCode, String productList); +} diff --git a/extension-iap/src/java/com/defold/iap/IPurchaseListener.java b/extension-iap/src/java/com/defold/iap/IPurchaseListener.java new file mode 100644 index 0000000..bda7ee3 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IPurchaseListener.java @@ -0,0 +1,5 @@ +package com.defold.iap; + +public interface IPurchaseListener { + public void onPurchaseResult(int responseCode, String purchaseData); +} diff --git a/extension-iap/src/java/com/defold/iap/IapAmazon.java b/extension-iap/src/java/com/defold/iap/IapAmazon.java new file mode 100644 index 0000000..089dc36 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IapAmazon.java @@ -0,0 +1,307 @@ +package com.defold.iap; + +import java.text.SimpleDateFormat; +import java.util.Map; +import java.util.Set; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Date; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Activity; +import android.os.Bundle; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import com.amazon.device.iap.PurchasingService; +import com.amazon.device.iap.PurchasingListener; +import com.amazon.device.iap.model.ProductDataResponse; +import com.amazon.device.iap.model.PurchaseUpdatesResponse; +import com.amazon.device.iap.model.PurchaseResponse; +import com.amazon.device.iap.model.UserDataResponse; +import com.amazon.device.iap.model.RequestId; +import com.amazon.device.iap.model.Product; +import com.amazon.device.iap.model.Receipt; +import com.amazon.device.iap.model.UserData; +import com.amazon.device.iap.model.FulfillmentResult; + +public class IapAmazon implements PurchasingListener { + + public static final String TAG = "iap"; + + private HashMap listProductsListeners; + private HashMap purchaseListeners; + + private Activity activity; + private boolean autoFinishTransactions; + + public IapAmazon(Activity activity, boolean autoFinishTransactions) { + this.activity = activity; + this.autoFinishTransactions = autoFinishTransactions; + this.listProductsListeners = new HashMap(); + this.purchaseListeners = new HashMap(); + PurchasingService.registerListener(activity, this); + } + + private void init() { + } + + public void stop() { + } + + public void listItems(final String skus, final IListProductsListener listener) { + final Set skuSet = new HashSet(); + for (String x : skus.split(",")) { + if (x.trim().length() > 0) { + if (!skuSet.contains(x)) { + skuSet.add(x); + } + } + } + + // It might seem unconventional to hold the lock while doing the function call, + // but it prevents a race condition, as the API does not allow supplying own + // requestId which could be generated ahead of time. + synchronized (listProductsListeners) { + RequestId req = PurchasingService.getProductData(skuSet); + if (req != null) { + listProductsListeners.put(req, listener); + } else { + Log.e(TAG, "Did not expect a null requestId"); + } + } + } + + public void buy(final String product, final IPurchaseListener listener) { + synchronized (purchaseListeners) { + RequestId req = PurchasingService.purchase(product); + if (req != null) { + purchaseListeners.put(req, listener); + } else { + Log.e(TAG, "Did not expect a null requestId"); + } + } + } + + public void finishTransaction(final String receipt, final IPurchaseListener listener) { + if(this.autoFinishTransactions) { + return; + } + PurchasingService.notifyFulfillment(receipt, FulfillmentResult.FULFILLED); + } + + private void doGetPurchaseUpdates(final IPurchaseListener listener, final boolean reset) { + synchronized (purchaseListeners) { + RequestId req = PurchasingService.getPurchaseUpdates(reset); + if (req != null) { + purchaseListeners.put(req, listener); + } else { + Log.e(TAG, "Did not expect a null requestId"); + } + } + } + + public void processPendingConsumables(final IPurchaseListener listener) { + // reset = false means getting any new receipts since the last call. + doGetPurchaseUpdates(listener, false); + } + + public void restore(final IPurchaseListener listener) { + // reset = true means getting all transaction history, although consumables + // are not included, only entitlements, after testing. + doGetPurchaseUpdates(listener, true); + } + + public static String toISO8601(final Date date) { + String formatted = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").format(date); + return formatted.substring(0, 22) + ":" + formatted.substring(22); + } + + private JSONObject makeTransactionObject(final UserData user, final Receipt receipt, int state) throws JSONException { + JSONObject transaction = new JSONObject(); + transaction.put("ident", receipt.getSku()); + transaction.put("state", state); + transaction.put("date", toISO8601(receipt.getPurchaseDate())); + transaction.put("trans_ident", receipt.getReceiptId()); + transaction.put("receipt", receipt.getReceiptId()); + + // Only for amazon (this far), but required for using their server side receipt validation. + transaction.put("is_sandbox_mode", PurchasingService.IS_SANDBOX_MODE); + transaction.put("user_id", user.getUserId()); + + // According to documentation, cancellation support has to be enabled per item, and this is + // not officially supported by any other IAP provider, and it is not expected to be used here either. + // + // But enforcing the use of only non-cancelable items is not possible either; so include these flags + // for completeness. + if (receipt.getCancelDate() != null) + transaction.put("cancel_date", toISO8601(receipt.getCancelDate())); + transaction.put("canceled", receipt.isCanceled()); + return transaction; + } + + // This callback method is invoked when an ProductDataResponse is available for a request initiated by PurchasingService.getProductData(java.util.Set). + @Override + public void onProductDataResponse(ProductDataResponse productDataResponse) { + RequestId reqId = productDataResponse.getRequestId(); + IListProductsListener listener; + synchronized (this.listProductsListeners) { + listener = this.listProductsListeners.get(reqId); + if (listener == null) { + Log.e(TAG, "No listener found for request " + reqId.toString()); + return; + } + this.listProductsListeners.remove(reqId); + } + + if (productDataResponse.getRequestStatus() != ProductDataResponse.RequestStatus.SUCCESSFUL) { + listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + } else { + Map products = productDataResponse.getProductData(); + try { + JSONObject data = new JSONObject(); + for (Map.Entry entry : products.entrySet()) { + String key = entry.getKey(); + Product product = entry.getValue(); + JSONObject item = new JSONObject(); + item.put("ident", product.getSku()); + item.put("title", product.getTitle()); + item.put("description", product.getDescription()); + if (product.getPrice() != null) { + String priceString = product.getPrice(); + item.put("price_string", priceString); + // Based on return values from getPrice: https://developer.amazon.com/public/binaries/content/assets/javadoc/in-app-purchasing-api/com/amazon/inapp/purchasing/item.html + item.put("price", priceString.replaceAll("[^0-9.,]", "")); + } + data.put(key, item); + } + listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_OK, data.toString()); + } catch (JSONException e) { + listener.onProductsResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + } + } + } + + // Convenience function for getting and removing a purchaseListener (used for more than one operation). + private IPurchaseListener pickPurchaseListener(RequestId requestId) { + synchronized (this.purchaseListeners) { + IPurchaseListener listener = this.purchaseListeners.get(requestId); + if (listener != null) { + this.purchaseListeners.remove(requestId); + return listener; + } + } + return null; + } + + // This callback method is invoked when a PurchaseResponse is available for a purchase initiated by PurchasingService.purchase(String). + @Override + public void onPurchaseResponse(PurchaseResponse purchaseResponse) { + + IPurchaseListener listener = pickPurchaseListener(purchaseResponse.getRequestId()); + if (listener == null) { + Log.e(TAG, "No listener found for request: " + purchaseResponse.getRequestId().toString()); + return; + } + + int code; + String data = null; + String fulfilReceiptId = null; + + switch (purchaseResponse.getRequestStatus()) { + case SUCCESSFUL: + { + try { + code = IapJNI.BILLING_RESPONSE_RESULT_OK; + data = makeTransactionObject(purchaseResponse.getUserData(), purchaseResponse.getReceipt(), IapJNI.TRANS_STATE_PURCHASED).toString(); + fulfilReceiptId = purchaseResponse.getReceipt().getReceiptId(); + } catch (JSONException e) { + Log.e(TAG, "JSON Exception occured: " + e.toString()); + code = IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR; + } + } + break; + case ALREADY_PURCHASED: + code = IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED; + break; + case INVALID_SKU: + code = IapJNI.BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE; + break; + case FAILED: + case NOT_SUPPORTED: + default: + code = IapJNI.BILLING_RESPONSE_RESULT_ERROR; + break; + } + + listener.onPurchaseResult(code, data); + + if (fulfilReceiptId != null && autoFinishTransactions) { + PurchasingService.notifyFulfillment(fulfilReceiptId, FulfillmentResult.FULFILLED); + } + } + + // This callback method is invoked when a PurchaseUpdatesResponse is available for a request initiated by PurchasingService.getPurchaseUpdates(boolean). + @Override + public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse purchaseUpdatesResponse) { + + // The documentation seems to be a little misguiding regarding how to handle this. + // This call is in response to getPurchaseUpdates() which can be called in two modes + // + // 1) Get all receipts since last call (reset = true) + // 2) Get the whole transaction history. + // + // The result can carry the flag hasMore() where it is required to call getPurchaseUpdates again. See docs: + // https://developer.amazon.com/public/apis/earn/in-app-purchasing/docs-v2/implementing-iap-2.0 + // + // Examples indicate it should be called with the same value for 'reset' the secon time around + // but actual testing ends up in an infinite loop where the same results are returned over and over. + // + // So here getPurchaseUpdates is called with result=false to fetch the next round of receipts. + + RequestId reqId = purchaseUpdatesResponse.getRequestId(); + IPurchaseListener listener = pickPurchaseListener(reqId); + if (listener == null) { + Log.e(TAG, "No listener found for request " + reqId.toString()); + return; + } + + switch (purchaseUpdatesResponse.getRequestStatus()) { + case SUCCESSFUL: + { + try { + for (Receipt receipt : purchaseUpdatesResponse.getReceipts()) { + JSONObject trans = makeTransactionObject(purchaseUpdatesResponse.getUserData(), receipt, IapJNI.TRANS_STATE_PURCHASED); + listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_OK, trans.toString()); + if(autoFinishTransactions) { + PurchasingService.notifyFulfillment(receipt.getReceiptId(), FulfillmentResult.FULFILLED); + } + } + if (purchaseUpdatesResponse.hasMore()) { + doGetPurchaseUpdates(listener, false); + } + } catch (JSONException e) { + Log.e(TAG, "JSON Exception occured: " + e.toString()); + listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_DEVELOPER_ERROR, null); + } + } + break; + case FAILED: + case NOT_SUPPORTED: + default: + listener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + break; + } + } + + // This callback method is invoked when a UserDataResponse is available for a request initiated by PurchasingService.getUserData(). + @Override + public void onUserDataResponse(UserDataResponse userDataResponse) { + // Intentionally left un-implemented; not used. + } +} diff --git a/extension-iap/src/java/com/defold/iap/IapGooglePlay.java b/extension-iap/src/java/com/defold/iap/IapGooglePlay.java new file mode 100644 index 0000000..8e60ce4 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IapGooglePlay.java @@ -0,0 +1,496 @@ +package com.defold.iap; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Activity; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.util.Log; + +import com.android.vending.billing.IInAppBillingService; + +public class IapGooglePlay implements Handler.Callback { + public static final String PARAM_PRODUCT = "product"; + public static final String PARAM_PRODUCT_TYPE = "product_type"; + public static final String PARAM_PURCHASE_DATA = "purchase_data"; + public static final String PARAM_AUTOFINISH_TRANSACTIONS = "auto_finish_transactions"; + public static final String PARAM_MESSENGER = "com.defold.iap.messenger"; + + public static final String RESPONSE_CODE = "RESPONSE_CODE"; + public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; + public static final String RESPONSE_BUY_INTENT = "BUY_INTENT"; + public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA"; + public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE"; + public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; + public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; + public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; + public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; + + public static enum Action { + BUY, + RESTORE, + PROCESS_PENDING_CONSUMABLES, + FINISH_TRANSACTION + } + + public static final String TAG = "iap"; + + private Activity activity; + private Handler handler; + private Messenger messenger; + private ServiceConnection serviceConn; + private IInAppBillingService service; + + private SkuDetailsThread skuDetailsThread; + private BlockingQueue skuRequestQueue = new ArrayBlockingQueue(16); + + private IPurchaseListener purchaseListener; + private boolean initialized; + private boolean autoFinishTransactions; + + private static interface ISkuRequestListener { + public void onProducts(int resultCode, JSONObject products); + } + + private static class SkuRequest { + private ArrayList skuList; + private ISkuRequestListener listener; + + public SkuRequest(ArrayList skuList, ISkuRequestListener listener) { + this.skuList = skuList; + this.listener = listener; + } + } + + private class SkuDetailsThread extends Thread { + public boolean stop = false; + + private void addProductsFromBundle(Bundle skuDetails, JSONObject products) throws JSONException { + int response = skuDetails.getInt("RESPONSE_CODE"); + if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { + ArrayList responseList = skuDetails.getStringArrayList("DETAILS_LIST"); + + for (String r : responseList) { + JSONObject product = new JSONObject(r); + products.put(product.getString("productId"), product); + } + } + else { + Log.e(TAG, "Failed to fetch product list: " + response); + } + } + + @Override + public void run() { + while (!stop) { + try { + SkuRequest sr = skuRequestQueue.take(); + if (service == null) { + Log.wtf(TAG, "service is null"); + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + continue; + } + if (activity == null) { + Log.wtf(TAG, "activity is null"); + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + continue; + } + + String packageName = activity.getPackageName(); + if (packageName == null) + { + Log.wtf(TAG, "activity packageName is null"); + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + continue; + } + + try { + Bundle querySkus = new Bundle(); + querySkus.putStringArrayList("ITEM_ID_LIST", sr.skuList); + + JSONObject products = new JSONObject(); + + Bundle inappSkuDetails = service.getSkuDetails(3, packageName, "inapp", querySkus); + addProductsFromBundle(inappSkuDetails, products); + + Bundle subscriptionSkuDetails = service.getSkuDetails(3, packageName, "subs", querySkus); + addProductsFromBundle(subscriptionSkuDetails, products); + + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_OK, products); + + } catch (RemoteException e) { + Log.e(TAG, "Failed to fetch product list", e); + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + } catch (JSONException e) { + Log.e(TAG, "Failed to fetch product list", e); + sr.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_ERROR, null); + } + } catch (InterruptedException e) { + continue; + } + } + } + } + + public IapGooglePlay(Activity activity, boolean autoFinishTransactions) { + this.activity = activity; + this.autoFinishTransactions = autoFinishTransactions; + } + + private void init() { + // NOTE: We must create Handler lazily as construction of + // handlers must be in the context of a "looper" on Android + + if (this.initialized) + return; + + this.initialized = true; + this.handler = new Handler(this); + this.messenger = new Messenger(this.handler); + + serviceConn = new ServiceConnection() { + + @Override + public void onServiceDisconnected(ComponentName name) { + Log.v(TAG, "IAP disconnected"); + service = null; + } + + @Override + public void onServiceConnected(ComponentName name, IBinder binderService) { + Log.v(TAG, "IAP connected"); + service = IInAppBillingService.Stub.asInterface(binderService); + skuDetailsThread = new SkuDetailsThread(); + skuDetailsThread.start(); + } + }; + + Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); + // Limit intent to vending package + serviceIntent.setPackage("com.android.vending"); + List intentServices = activity.getPackageManager().queryIntentServices(serviceIntent, 0); + if (intentServices != null && !intentServices.isEmpty()) { + // service available to handle that Intent + activity.bindService(serviceIntent, serviceConn, Context.BIND_AUTO_CREATE); + } else { + serviceConn = null; + Log.e(TAG, "Billing service unavailable on device."); + } + } + + public void stop() { + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + if (serviceConn != null) { + activity.unbindService(serviceConn); + serviceConn = null; + } + if (skuDetailsThread != null) { + skuDetailsThread.stop = true; + skuDetailsThread.interrupt(); + try { + skuDetailsThread.join(); + } catch (InterruptedException e) { + Log.wtf(TAG, "Failed to join thread", e); + } + } + } + }); + } + + + private void queueSkuRequest(final SkuRequest request) { + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + init(); + + if (serviceConn != null) { + try { + skuRequestQueue.put(request); + } catch (InterruptedException e) { + Log.wtf(TAG, "Failed to add sku request", e); + request.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, null); + } + } else { + request.listener.onProducts(IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, null); + } + } + }); + } + + public void listItems(final String skus, final IListProductsListener listener) { + ArrayList skuList = new ArrayList(); + for (String x : skus.split(",")) { + if (x.trim().length() > 0) { + skuList.add(x); + } + } + + queueSkuRequest(new SkuRequest(skuList, new ISkuRequestListener() { + @Override + public void onProducts(int resultCode, JSONObject products) { + if (products != null && products.length() > 0) { + try { + // go through all of the products and convert them into + // the generic product format used for all IAP implementations + Iterator keys = products.keys(); + while(keys.hasNext()) { + String key = keys.next(); + if (products.get(key) instanceof JSONObject ) { + JSONObject product = products.getJSONObject(key); + products.put(key, convertProduct(product)); + } + } + listener.onProductsResult(resultCode, products.toString()); + } + catch(JSONException e) { + Log.wtf(TAG, "Failed to convert products", e); + listener.onProductsResult(resultCode, null); + } + } + else { + listener.onProductsResult(resultCode, null); + } + } + })); + } + + // Convert the product data into the generic format shared between all Defold IAP implementations + private static JSONObject convertProduct(JSONObject product) { + try { + // Deep copy and modify + JSONObject p = new JSONObject(product.toString()); + p.put("price_string", p.get("price")); + p.put("ident", p.get("productId")); + // It is not yet possible to obtain the price (num) and currency code on Android for the correct locale/region. + // They have a currency code (price_currency_code), which reflects the merchant's locale, instead of the user's + // https://code.google.com/p/marketbilling/issues/detail?id=93&q=currency%20code&colspec=ID%20Type%20Status%20Google%20Priority%20Milestone%20Owner%20Summary + double price = 0.0; + if (p.has("price_amount_micros")) { + price = p.getLong("price_amount_micros") * 0.000001; + } + String currency_code = "Unknown"; + if (p.has("price_currency_code")) { + currency_code = (String)p.get("price_currency_code"); + } + p.put("currency_code", currency_code); + p.put("price", price); + + p.remove("productId"); + p.remove("type"); + p.remove("price_amount_micros"); + p.remove("price_currency_code"); + return p; + } catch (JSONException e) { + Log.wtf(TAG, "Failed to convert product json", e); + } + + return null; + } + + private void buyProduct(final String product, final String type, final IPurchaseListener listener) { + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + init(); + IapGooglePlay.this.purchaseListener = listener; + Intent intent = new Intent(activity, IapGooglePlayActivity.class); + intent.putExtra(PARAM_MESSENGER, messenger); + intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); + intent.putExtra(PARAM_PRODUCT, product); + intent.putExtra(PARAM_PRODUCT_TYPE, type); + intent.setAction(Action.BUY.toString()); + activity.startActivity(intent); + } + }); + } + + public void buy(final String product, final IPurchaseListener listener) { + ArrayList skuList = new ArrayList(); + skuList.add(product); + queueSkuRequest(new SkuRequest(skuList, new ISkuRequestListener() { + @Override + public void onProducts(int resultCode, JSONObject products) { + String type = "inapp"; + if (resultCode == IapJNI.BILLING_RESPONSE_RESULT_OK && products != null) { + try { + JSONObject productData = products.getJSONObject(product); + type = productData.getString("type"); + } + catch(JSONException e) { + Log.wtf(TAG, "Failed to get product type before buying, assuming type 'inapp'", e); + } + } + else { + Log.wtf(TAG, "Failed to list product before buying, assuming type 'inapp'"); + } + buyProduct(product, type, listener); + } + })); + } + + public void finishTransaction(final String receipt, final IPurchaseListener listener) { + if(IapGooglePlay.this.autoFinishTransactions) { + return; + } + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + init(); + IapGooglePlay.this.purchaseListener = listener; + Intent intent = new Intent(activity, IapGooglePlayActivity.class); + intent.putExtra(PARAM_MESSENGER, messenger); + intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, false); + intent.putExtra(PARAM_PURCHASE_DATA, receipt); + intent.setAction(Action.FINISH_TRANSACTION.toString()); + activity.startActivity(intent); + } + }); + } + + public void processPendingConsumables(final IPurchaseListener listener) { + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + init(); + IapGooglePlay.this.purchaseListener = listener; + Intent intent = new Intent(activity, IapGooglePlayActivity.class); + intent.putExtra(PARAM_MESSENGER, messenger); + intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); + intent.setAction(Action.PROCESS_PENDING_CONSUMABLES.toString()); + activity.startActivity(intent); + } + }); + } + + public void restore(final IPurchaseListener listener) { + this.activity.runOnUiThread(new Runnable() { + @Override + public void run() { + init(); + IapGooglePlay.this.purchaseListener = listener; + Intent intent = new Intent(activity, IapGooglePlayActivity.class); + intent.putExtra(PARAM_MESSENGER, messenger); + intent.putExtra(PARAM_AUTOFINISH_TRANSACTIONS, IapGooglePlay.this.autoFinishTransactions); + intent.setAction(Action.RESTORE.toString()); + activity.startActivity(intent); + } + }); + } + + public static String toISO8601(final Date date) { + String formatted = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").format(date); + return formatted.substring(0, 22) + ":" + formatted.substring(22); + } + + private static String convertPurchase(String purchase, String signature) { + try { + JSONObject p = new JSONObject(purchase); + p.put("ident", p.get("productId")); + p.put("state", IapJNI.TRANS_STATE_PURCHASED); + + // We check if orderId is actually set here, otherwise we return a blank string. + // This is what Google used to do, but after some updates around June/May 2016 + // they stopped to include the orderId key at all for test purchases. See: DEF-1940 + if (p.has("orderId")) { + p.put("trans_ident", p.get("orderId")); + } else { + p.put("trans_ident", ""); + } + + p.put("date", toISO8601(new Date(p.getLong("purchaseTime")))); + // Receipt is the complete json data + // http://robertomurray.co.uk/blog/2013/server-side-google-play-in-app-billing-receipt-validation-and-testing/ + p.put("receipt", purchase); + p.put("signature", signature); + // TODO: How to simulate original_trans on iOS? + + p.remove("packageName"); + p.remove("orderId"); + p.remove("productId"); + p.remove("developerPayload"); + p.remove("purchaseTime"); + p.remove("purchaseState"); + p.remove("purchaseToken"); + + return p.toString(); + + } catch (JSONException e) { + Log.wtf(TAG, "Failed to convert purchase json", e); + } + + return null; + } + + @Override + public boolean handleMessage(Message msg) { + Bundle bundle = msg.getData(); + + String actionString = bundle.getString("action"); + if (actionString == null) { + return false; + } + + if (purchaseListener == null) { + Log.wtf(TAG, "No purchase listener set"); + return false; + } + + Action action = Action.valueOf(actionString); + + if (action == Action.BUY) { + int responseCode = bundle.getInt(RESPONSE_CODE); + String purchaseData = bundle.getString(RESPONSE_INAPP_PURCHASE_DATA); + String dataSignature = bundle.getString(RESPONSE_INAPP_SIGNATURE); + + if (purchaseData != null && dataSignature != null) { + purchaseData = convertPurchase(purchaseData, dataSignature); + } else { + purchaseData = ""; + } + + purchaseListener.onPurchaseResult(responseCode, purchaseData); + } else if (action == Action.RESTORE) { + Bundle items = bundle.getBundle("items"); + + if (!items.containsKey(RESPONSE_INAPP_ITEM_LIST)) { + purchaseListener.onPurchaseResult(IapJNI.BILLING_RESPONSE_RESULT_ERROR, ""); + return true; + } + + ArrayList ownedSkus = items.getStringArrayList(RESPONSE_INAPP_ITEM_LIST); + ArrayList purchaseDataList = items.getStringArrayList(RESPONSE_INAPP_PURCHASE_DATA_LIST); + ArrayList signatureList = items.getStringArrayList(RESPONSE_INAPP_SIGNATURE_LIST); + for (int i = 0; i < ownedSkus.size(); ++i) { + int c = IapJNI.BILLING_RESPONSE_RESULT_OK; + String pd = convertPurchase(purchaseDataList.get(i), signatureList.get(i)); + if (pd == null) { + pd = ""; + c = IapJNI.BILLING_RESPONSE_RESULT_ERROR; + } + purchaseListener.onPurchaseResult(c, pd); + } + } + return true; + } +} diff --git a/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java b/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java new file mode 100644 index 0000000..ef12001 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IapGooglePlayActivity.java @@ -0,0 +1,371 @@ +package com.defold.iap; + +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONException; +import org.json.JSONObject; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentSender.SendIntentException; +import android.content.ServiceConnection; +import android.content.pm.ResolveInfo; +import android.os.Bundle; +import android.os.IBinder; +import android.os.Message; +import android.os.Messenger; +import android.os.RemoteException; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup.LayoutParams; + +import com.android.vending.billing.IInAppBillingService; +import com.defold.iap.IapGooglePlay.Action; + +public class IapGooglePlayActivity extends Activity { + + private boolean hasPendingPurchases = false; + private boolean autoFinishTransactions = true; + private boolean isDone = false; + private Messenger messenger; + ServiceConnection serviceConn; + IInAppBillingService service; + + // NOTE: Code from "trivialdrivesample" + int getResponseCodeFromBundle(Bundle b) { + Object o = b.get(IapGooglePlay.RESPONSE_CODE); + if (o == null) { + Log.d(IapGooglePlay.TAG, "Bundle with null response code, assuming OK (known issue)"); + return IapJNI.BILLING_RESPONSE_RESULT_OK; + } else if (o instanceof Integer) + return ((Integer) o).intValue(); + else if (o instanceof Long) + return (int) ((Long) o).longValue(); + else { + Log.e(IapGooglePlay.TAG, "Unexpected type for bundle response code."); + Log.e(IapGooglePlay.TAG, o.getClass().getName()); + throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); + } + } + + private void sendBuyError(int error) { + Bundle bundle = new Bundle(); + bundle.putString("action", Action.BUY.toString()); + + bundle.putInt(IapGooglePlay.RESPONSE_CODE, error); + Message msg = new Message(); + msg.setData(bundle); + + try { + messenger.send(msg); + } catch (RemoteException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); + } + this.finish(); + } + + private void buy(String product, String productType) { + // Flush any pending items, in order to be able to buy the same (new) product again + processPendingConsumables(); + + try { + Bundle buyIntentBundle = service.getBuyIntent(3, getPackageName(), product, productType, ""); + int response = getResponseCodeFromBundle(buyIntentBundle); + if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { + hasPendingPurchases = true; + PendingIntent pendingIntent = buyIntentBundle.getParcelable("BUY_INTENT"); + startIntentSenderForResult(pendingIntent.getIntentSender(), 1001, new Intent(), Integer.valueOf(0), Integer.valueOf(0), Integer.valueOf(0)); + } else if (response == IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED) { + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED); + } else { + sendBuyError(response); + } + + } catch (RemoteException e) { + Log.e(IapGooglePlay.TAG, String.format("Failed to buy", e)); + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } catch (SendIntentException e) { + Log.e(IapGooglePlay.TAG, String.format("Failed to buy", e)); + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } + } + + private boolean consume(String purchaseData) { + try { + if (purchaseData == null) { + Log.e(IapGooglePlay.TAG, String.format("Failed to consume purchase, purchaseData was null!")); + return false; + } + + JSONObject pd = new JSONObject(purchaseData); + if (!pd.isNull("autoRenewing")) { + Log.i(IapGooglePlay.TAG, "Will not consume purchase since it is a subscription."); + return true; + } + String token = pd.getString("purchaseToken"); + int consumeResponse = service.consumePurchase(3, getPackageName(), token); + if (consumeResponse == IapJNI.BILLING_RESPONSE_RESULT_OK) { + return true; + } else { + Log.e(IapGooglePlay.TAG, String.format("Failed to consume purchase (%d)", consumeResponse)); + sendBuyError(consumeResponse); + } + } catch (RemoteException e) { + Log.e(IapGooglePlay.TAG, "Failed to consume purchase", e); + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } catch (JSONException e) { + Log.e(IapGooglePlay.TAG, "Failed to consume purchase", e); + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } + return false; + } + + private boolean processPurchase(String purchaseData, String signature) + { + if (this.autoFinishTransactions && !consume(purchaseData)) { + Log.e(IapGooglePlay.TAG, "Failed to consume and send message"); + return false; + } + + Bundle bundle = new Bundle(); + bundle.putString("action", Action.BUY.toString()); + bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_OK); + bundle.putString(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA, purchaseData); + bundle.putString(IapGooglePlay.RESPONSE_INAPP_SIGNATURE, signature); + + Message msg = new Message(); + msg.setData(bundle); + try { + messenger.send(msg); + return true; + } catch (RemoteException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); + return false; + } + } + + // Make buy response codes for all consumables not yet processed. + private void processPendingConsumables() { + try { + // Note: subscriptions cannot be consumed + // https://developer.android.com/google/play/billing/api.html#subs + Bundle items = service.getPurchases(3, getPackageName(), "inapp", null); + int response = getResponseCodeFromBundle(items); + if (response == IapJNI.BILLING_RESPONSE_RESULT_OK) { + ArrayList purchaseDataList = items.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST); + ArrayList signatureList = items.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST); + for (int i = 0; i < purchaseDataList.size(); ++i) { + String purchaseData = purchaseDataList.get(i); + String signature = signatureList.get(i); + if (!processPurchase(purchaseData, signature)) { + // abort and retry some other time + break; + } + } + } + } catch (RemoteException e) { + Log.e(IapGooglePlay.TAG, "Failed to process purchase", e); + } + } + + private void restore() { + int response = IapJNI.BILLING_RESPONSE_RESULT_ERROR; + Bundle bundle = new Bundle(); + bundle.putString("action", Action.RESTORE.toString()); + + Bundle items = new Bundle(); + try { + ArrayList purchaseItemList = new ArrayList(); + ArrayList purchaseDataList = new ArrayList(); + ArrayList signatureList = new ArrayList(); + + Bundle inapp = service.getPurchases(3, getPackageName(), "inapp", null); + if (getResponseCodeFromBundle(inapp) == IapJNI.BILLING_RESPONSE_RESULT_OK) { + purchaseItemList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST)); + purchaseDataList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST)); + signatureList.addAll(inapp.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST)); + } + + Bundle subs = service.getPurchases(3, getPackageName(), "subs", null); + if (getResponseCodeFromBundle(subs) == IapJNI.BILLING_RESPONSE_RESULT_OK) { + purchaseItemList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST)); + purchaseDataList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST)); + signatureList.addAll(subs.getStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST)); + } + + items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_ITEM_LIST, purchaseItemList); + items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA_LIST, purchaseDataList); + items.putStringArrayList(IapGooglePlay.RESPONSE_INAPP_SIGNATURE_LIST, signatureList); + } catch (RemoteException e) { + Log.e(IapGooglePlay.TAG, "Failed to restore purchases", e); + } + bundle.putBundle("items", items); + + bundle.putInt(IapGooglePlay.RESPONSE_CODE, response); + Message msg = new Message(); + msg.setData(bundle); + + try { + messenger.send(msg); + } catch (RemoteException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); + } + this.finish(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + View view = new View(this); + view.setBackgroundColor(0x10ffffff); + setContentView(view, new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); + + Intent intent = getIntent(); + final Bundle extras = intent.getExtras(); + this.messenger = (Messenger) extras.getParcelable(IapGooglePlay.PARAM_MESSENGER); + final Action action = Action.valueOf(intent.getAction()); + this.autoFinishTransactions = extras.getBoolean(IapGooglePlay.PARAM_AUTOFINISH_TRANSACTIONS); + + Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); + serviceIntent.setPackage("com.android.vending"); + List intentServices = getPackageManager().queryIntentServices(serviceIntent, 0); + if (intentServices != null && !intentServices.isEmpty()) { + // service available to handle that Intent + serviceConn = new ServiceConnection() { + @Override + public void onServiceDisconnected(ComponentName name) { + service = null; + } + + @Override + public void onServiceConnected(ComponentName name, IBinder serviceBinder) { + service = IInAppBillingService.Stub.asInterface(serviceBinder); + if (action == Action.BUY) { + buy(extras.getString(IapGooglePlay.PARAM_PRODUCT), extras.getString(IapGooglePlay.PARAM_PRODUCT_TYPE)); + } else if (action == Action.RESTORE) { + restore(); + } else if (action == Action.PROCESS_PENDING_CONSUMABLES) { + processPendingConsumables(); + finish(); + } else if (action == Action.FINISH_TRANSACTION) { + consume(extras.getString(IapGooglePlay.PARAM_PURCHASE_DATA)); + finish(); + } + } + }; + + bindService(serviceIntent, serviceConn, Context.BIND_AUTO_CREATE); + } else { + // Service will never be connected; just send unavailability message + Bundle bundle = new Bundle(); + bundle.putString("action", intent.getAction()); + bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE); + Message msg = new Message(); + msg.setData(bundle); + try { + messenger.send(msg); + } catch (RemoteException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); + } + this.finish(); + } + } + + @Override + public void finish() { + super.finish(); + this.isDone = true; + } + + @Override + protected void onDestroy() { + if (hasPendingPurchases) { + // Not sure connection is up so need to check here. + if (service != null) { + if(autoFinishTransactions) { + processPendingConsumables(); + } + } + hasPendingPurchases = false; + } + + if( !isDone ) + { + Intent intent = getIntent(); + + if( intent != null && intent.getComponent().getClassName().equals( getClass().getName() ) ) + { + Log.v(IapGooglePlay.TAG, "There's still an intent left: " + intent.getAction() ); + sendBuyError(IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } + } + + if (serviceConn != null) { + try + { + unbindService(serviceConn); + } catch (IllegalArgumentException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to unbind service", e); + } + } + super.onDestroy(); + } + + // NOTE: Code from "trivialdrivesample" + int getResponseCodeFromIntent(Intent i) { + Object o = i.getExtras().get(IapGooglePlay.RESPONSE_CODE); + if (o == null) { + Log.e(IapGooglePlay.TAG, "Intent with no response code, assuming OK (known issue)"); + return IapJNI.BILLING_RESPONSE_RESULT_OK; + } else if (o instanceof Integer) { + return ((Integer) o).intValue(); + } else if (o instanceof Long) { + return (int) ((Long) o).longValue(); + } else { + Log.e(IapGooglePlay.TAG, "Unexpected type for intent response code."); + Log.e(IapGooglePlay.TAG, o.getClass().getName()); + throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + Bundle bundle = null; + if (data != null) { + int responseCode = getResponseCodeFromIntent(data); + String purchaseData = data.getStringExtra(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA); + String dataSignature = data.getStringExtra(IapGooglePlay.RESPONSE_INAPP_SIGNATURE); + if (responseCode == IapJNI.BILLING_RESPONSE_RESULT_OK) { + processPurchase(purchaseData, dataSignature); + } else { + bundle = new Bundle(); + bundle.putString("action", Action.BUY.toString()); + bundle.putInt(IapGooglePlay.RESPONSE_CODE, responseCode); + bundle.putString(IapGooglePlay.RESPONSE_INAPP_PURCHASE_DATA, purchaseData); + bundle.putString(IapGooglePlay.RESPONSE_INAPP_SIGNATURE, dataSignature); + } + } else { + bundle = new Bundle(); + bundle.putString("action", Action.BUY.toString()); + bundle.putInt(IapGooglePlay.RESPONSE_CODE, IapJNI.BILLING_RESPONSE_RESULT_ERROR); + } + + // Send message if generated above + if (bundle != null) { + Message msg = new Message(); + msg.setData(bundle); + try { + messenger.send(msg); + } catch (RemoteException e) { + Log.wtf(IapGooglePlay.TAG, "Unable to send message", e); + } + } + + this.finish(); + } +} diff --git a/extension-iap/src/java/com/defold/iap/IapJNI.java b/extension-iap/src/java/com/defold/iap/IapJNI.java new file mode 100644 index 0000000..f333fb3 --- /dev/null +++ b/extension-iap/src/java/com/defold/iap/IapJNI.java @@ -0,0 +1,31 @@ +package com.defold.iap; + +public class IapJNI implements IListProductsListener, IPurchaseListener { + + // NOTE: Also defined in iap.h + public static final int TRANS_STATE_PURCHASING = 0; + public static final int TRANS_STATE_PURCHASED = 1; + public static final int TRANS_STATE_FAILED = 2; + public static final int TRANS_STATE_RESTORED = 3; + public static final int TRANS_STATE_UNVERIFIED = 4; + + public static final int BILLING_RESPONSE_RESULT_OK = 0; + public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; + public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2; + public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; + public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; + public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; + 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_NOT_OWNED = 8; + + public IapJNI() { + } + + @Override + public native void onProductsResult(int responseCode, String productList); + + @Override + public native void onPurchaseResult(int responseCode, String purchaseData); + +} diff --git a/game.project b/game.project new file mode 100644 index 0000000..f66136e --- /dev/null +++ b/game.project @@ -0,0 +1,23 @@ +[bootstrap] +main_collection = /main/main.collectionc + +[script] +shared_state = 1 + +[display] +width = 960 +height = 640 + +[android] +input_method = HiddenInputField +package = com.defold.iap + +[project] +title = extension-iap + +[library] +include_dirs = extension-iap + +[ios] +bundle_identifier = com.defold.iap + diff --git a/input/game.input_binding b/input/game.input_binding new file mode 100644 index 0000000..8ed1d4e --- /dev/null +++ b/input/game.input_binding @@ -0,0 +1,4 @@ +mouse_trigger { + input: MOUSE_BUTTON_1 + action: "touch" +} diff --git a/main/main.collection b/main/main.collection new file mode 100644 index 0000000..8be648d --- /dev/null +++ b/main/main.collection @@ -0,0 +1,37 @@ +name: "main" +scale_along_z: 0 +embedded_instances { + id: "go" + data: "components {\n" + " id: \"main\"\n" + " component: \"/main/main.gui\"\n" + " position {\n" + " x: 0.0\n" + " y: 0.0\n" + " z: 0.0\n" + " }\n" + " rotation {\n" + " x: 0.0\n" + " y: 0.0\n" + " z: 0.0\n" + " w: 1.0\n" + " }\n" + "}\n" + "" + position { + x: 0.0 + y: 0.0 + z: 0.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale3 { + x: 1.0 + y: 1.0 + z: 1.0 + } +} diff --git a/main/main.gui b/main/main.gui new file mode 100644 index 0000000..a19a00f --- /dev/null +++ b/main/main.gui @@ -0,0 +1,76 @@ +script: "/main/main.gui_script" +fonts { + name: "system_font" + font: "/builtins/fonts/system_font.font" +} +background_color { + x: 0.0 + y: 0.0 + z: 0.0 + w: 0.0 +} +nodes { + position { + x: 467.0 + y: 350.0 + z: 0.0 + w: 1.0 + } + rotation { + x: 0.0 + y: 0.0 + z: 0.0 + w: 1.0 + } + scale { + x: 2.0 + y: 2.0 + z: 1.0 + w: 1.0 + } + size { + x: 200.0 + y: 100.0 + z: 0.0 + w: 1.0 + } + color { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + type: TYPE_TEXT + blend_mode: BLEND_MODE_ALPHA + text: "" + font: "system_font" + id: "text" + xanchor: XANCHOR_NONE + yanchor: YANCHOR_NONE + pivot: PIVOT_CENTER + outline { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + shadow { + x: 1.0 + y: 1.0 + z: 1.0 + w: 1.0 + } + adjust_mode: ADJUST_MODE_FIT + line_break: false + layer: "" + inherit_alpha: true + alpha: 1.0 + outline_alpha: 1.0 + shadow_alpha: 1.0 + template_node_child: false + text_leading: 1.0 + text_tracking: 0.0 +} +material: "/builtins/materials/gui.material" +adjust_reference: ADJUST_REFERENCE_PARENT +max_nodes: 512 diff --git a/main/main.gui_script b/main/main.gui_script new file mode 100644 index 0000000..74c01d4 --- /dev/null +++ b/main/main.gui_script @@ -0,0 +1,3 @@ +function init(self) + +end \ No newline at end of file