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 0000000..8d8e995 Binary files /dev/null and b/extension-iap/lib/android/in-app-purchasing-2.0.61.jar differ 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