我创建了一个概念验证的 PhoneGap 应用程序来测试 iOS 上的应用内购买机制。该应用程序基于 Phonegap 2.9.0,使用这个 InAppPurchase 插件,并且大致基于解释如何使用该插件的本教程。
问题是在成功从 Apple 服务器接收到 InApp Purchase 数据后,Objective-C 插件没有执行 Javascript 回调函数。我不知道为什么 JS 没有被执行,所以希望有人能发现问题......?
当我使用 XCode 4.6.3 在 iPhone 4S 上运行我的应用程序时,一切正常,直到 StoreKit API在接收到 InApp Purchase 项目的产品数据后异步调用productsRequest
成功回调。InAppPurchase.m
我可以NSLog
在第 213 行看到语句的输出,该输出callbackArgs
在 XCode 日志窗口中输出,其中包含 InApp 购买项目的正确详细信息。之后的行将导致执行 Javascript 成功回调,该回调在第 128 行定义InAppPurchase.js
并在第 140 行传入,但第 129 行的日志输出永远不会出现在 XCode 日志窗口中。
如果我在 XCode 中使用断点单步执行 Objective-C,我可以看到该callbackId
变量具有合理的值,并且我可以单步self.plugin.commandDelegate
执行 Cordova 代码到构造 JS 回调的位置,这一切看起来都很好,但 JS 从来没有真正运行。
我也尝试在应用程序中使用 Phonegap 2.7.0,但结果是一样的。
我的应用程序的 XCode 项目可以从这里下载
2013 年 8 月 19 日更新:关于如何使用此插件的教程 的作者已确认该插件的此问题是可重现的,但尚未找到原因/解决方案。我还没有看到这个插件成功运行的例子。
源代码和输出
XCode 的日志输出(请原谅 Fraggles 和 Wombles,我是 80 年代的孩子):
2013-08-07 16:16:48.137 InappTest[347:907] Multi-tasking -> Device: YES, App: YES
2013-08-07 16:16:48.959 InappTest[347:907] Resetting plugins due to page load.
2013-08-07 16:16:49.342 InappTest[347:907] Finished load of: file:///var/mobile/Applications/62132E03-9DE3-4B01-8066-1978CABDD91F/InappTest.app/www/index.html
2013-08-07 16:16:49.479 InappTest[347:907] DEPRECATION NOTICE: The Connection ReachableViaWWAN return value of '2g' is deprecated as of Cordova version 2.6.0 and will be changed to 'cellular' in a future release.
2013-08-07 16:16:49.514 InappTest[347:907] TRACE: Environment ready
2013-08-07 16:16:49.516 InappTest[347:907] Device ready
2013-08-07 16:16:49.517 InappTest[347:907] Initialising IAP...
2013-08-07 16:16:49.519 InappTest[347:907] InAppPurchase[js]: setup ok
2013-08-07 16:16:49.520 InappTest[347:907] IAP ready
2013-08-07 16:16:49.521 InappTest[347:907] InAppPurchase[js]: load ["uk.co.workingedge.test.inapp.fraggleguide","uk.co.workingedge.test.inapp.wombleguide"]
2013-08-07 16:16:49.522 InappTest[347:907] InAppPurchase[objc]: Getting products data
2013-08-07 16:16:49.524 InappTest[347:907] InAppPurchase[objc]: Set has 2 elements
2013-08-07 16:16:49.525 InappTest[347:907] InAppPurchase[objc]: - uk.co.workingedge.test.inapp.fraggleguide
2013-08-07 16:16:49.526 InappTest[347:907] InAppPurchase[objc]: - uk.co.workingedge.test.inapp.wombleguide
2013-08-07 16:16:49.527 InappTest[347:907] InAppPurchase[objc]: start
2013-08-07 16:16:51.056 InappTest[347:907] InAppPurchase[objc]: productsRequest: didReceiveResponse:
2013-08-07 16:16:51.058 InappTest[347:907] InAppPurchase[objc]: Has 2 validProducts
2013-08-07 16:16:51.058 InappTest[347:907] InAppPurchase[objc]: - uk.co.workingedge.test.inapp.fraggleguide: Fraggle Guide
2013-08-07 16:16:51.062 InappTest[347:907] InAppPurchase[objc]: - uk.co.workingedge.test.inapp.wombleguide: Womble Guide
2013-08-07 16:16:51.065 InappTest[347:907] InAppPurchase[objc]: productsRequest: didReceiveResponse: sendPluginResult: (
(
{
description = "Guide to Fraggles";
id = "uk.co.workingedge.test.inapp.fraggleguide";
price = "\U00a30.69";
title = "Fraggle Guide";
},
{
description = "Guide to Wombles";
id = "uk.co.workingedge.test.inapp.wombleguide";
price = "\U00a30.69";
title = "Womble Guide";
}
),
(
)
)
[END OF LOG]
InAppPurchase.m
//
// InAppPurchase.m
//
// Created by Matt Kane on 20/02/2011.
// Copyright (c) Matt Kane 2011. All rights reserved.
// Copyright (c) Jean-Christophe Hoelt 2013
//
#import "InAppPurchase.h"
// Help create NSNull objects for nil items (since neither NSArray nor NSDictionary can store nil values).
#define NILABLE(obj) ((obj) != nil ? (NSObject *)(obj) : (NSObject *)[NSNull null])
// To avoid compilation warning, declare JSONKit and SBJson's
// category methods without including their header files.
@interface NSArray (StubsForSerializers)
- (NSString *)JSONString;
- (NSString *)JSONRepresentation;
@end
// Helper category method to choose which JSON serializer to use.
@interface NSArray (JSONSerialize)
- (NSString *)JSONSerialize;
@end
@implementation NSArray (JSONSerialize)
- (NSString *)JSONSerialize {
return [self respondsToSelector:@selector(JSONString)] ? [self JSONString] : [self JSONRepresentation];
}
@end
@implementation InAppPurchase
@synthesize list;
-(void) setup: (CDVInvokedUrlCommand*)command {
CDVPluginResult* pluginResult = nil;
self.list = [[NSMutableDictionary alloc] init];
[[SKPaymentQueue defaultQueue] addTransactionObserver:self];
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsString:@"InAppPurchase initialized"];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
}
/**
* Request product data for the given productIds.
* See js for further documentation.
*/
- (void) load: (CDVInvokedUrlCommand*)command
{
NSLog(@"InAppPurchase[objc]: Getting products data");
NSArray *inArray = [command.arguments objectAtIndex:0];
if ((unsigned long)[inArray count] == 0) {
NSLog(@"InAppPurchase[objc]: empty array");
NSArray *callbackArgs = [NSArray arrayWithObjects: nil, nil, nil];
CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:callbackArgs];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
return;
}
if (![[inArray objectAtIndex:0] isKindOfClass:[NSString class]]) {
NSLog(@"InAppPurchase[objc]: not an array of NSString");
CDVPluginResult* pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_ERROR messageAsString:@"Invalid arguments"];
[self.commandDelegate sendPluginResult:pluginResult callbackId:command.callbackId];
return;
}
NSSet *productIdentifiers = [NSSet setWithArray:inArray];
NSLog(@"InAppPurchase[objc]: Set has %li elements", (unsigned long)[productIdentifiers count]);
for (NSString *item in productIdentifiers) {
NSLog(@"InAppPurchase[objc]: - %@", item);
}
SKProductsRequest *productsRequest = [[SKProductsRequest alloc] initWithProductIdentifiers:productIdentifiers];
BatchProductsRequestDelegate* delegate = [[[BatchProductsRequestDelegate alloc] init] retain];
delegate.plugin = self;
delegate.command = command;
productsRequest.delegate = delegate;
NSLog(@"InAppPurchase[objc]: start");
[productsRequest start];
}
- (void) purchase: (CDVInvokedUrlCommand*)command
{
NSLog(@"InAppPurchase[objc]: About to do IAP");
id identifier = [command.arguments objectAtIndex:0];
id quantity = [command.arguments objectAtIndex:1];
SKMutablePayment *payment = [SKMutablePayment paymentWithProduct:[self.list objectForKey:identifier]];
if ([quantity respondsToSelector:@selector(integerValue)]) {
payment.quantity = [quantity integerValue];
}
[[SKPaymentQueue defaultQueue] addPayment:payment];
}
- (void) restoreCompletedTransactions: (CDVInvokedUrlCommand*)command
{
[[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
}
// SKPaymentTransactionObserver methods
// called when the transaction status is updated
//
- (void)paymentQueue:(SKPaymentQueue*)queue updatedTransactions:(NSArray*)transactions
{
NSString *state, *error, *transactionIdentifier, *transactionReceipt, *productId;
NSInteger errorCode;
for (SKPaymentTransaction *transaction in transactions)
{
error = state = transactionIdentifier = transactionReceipt = productId = @"";
errorCode = 0;
switch (transaction.transactionState)
{
case SKPaymentTransactionStatePurchasing:
NSLog(@"InAppPurchase[objc]: Purchasing...");
continue;
case SKPaymentTransactionStatePurchased:
state = @"PaymentTransactionStatePurchased";
transactionIdentifier = transaction.transactionIdentifier;
transactionReceipt = [[transaction transactionReceipt] base64EncodedString];
productId = transaction.payment.productIdentifier;
break;
case SKPaymentTransactionStateFailed:
state = @"PaymentTransactionStateFailed";
error = transaction.error.localizedDescription;
errorCode = transaction.error.code;
NSLog(@"InAppPurchase[objc]: error %d %@", errorCode, error);
break;
case SKPaymentTransactionStateRestored:
state = @"PaymentTransactionStateRestored";
transactionIdentifier = transaction.originalTransaction.transactionIdentifier;
transactionReceipt = [[transaction transactionReceipt] base64EncodedString];
productId = transaction.originalTransaction.payment.productIdentifier;
break;
default:
NSLog(@"InAppPurchase[objc]: Invalid state");
continue;
}
NSLog(@"InAppPurchase[objc]: state: %@", state);
NSArray *callbackArgs = [NSArray arrayWithObjects:
NILABLE(state),
[NSNumber numberWithInt:errorCode],
NILABLE(error),
NILABLE(transactionIdentifier),
NILABLE(productId),
NILABLE(transactionReceipt),
nil];
CDVPluginResult* pluginResult = nil;
pluginResult = [CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray: callbackArgs];
NSString *js = [NSString
stringWithFormat:@"window.storekit.updatedTransactionCallback.apply(window.storekit, %@)",
[callbackArgs JSONSerialize]];
NSLog(@"InAppPurchase[objc]: js: %@", js);
[self.commandDelegate evalJs:js];
[[SKPaymentQueue defaultQueue] finishTransaction:transaction];
}
}
- (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error
{
/* NSString *js = [NSString stringWithFormat:
@"window.storekit.onRestoreCompletedTransactionsFailed(%d)", error.code];
[self writeJavascript: js]; */
}
- (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue
{
/* NSString *js = @"window.storekit.onRestoreCompletedTransactionsFinished()";
[self writeJavascript: js]; */
}
@end
/**
* Receives product data for multiple productIds and passes arrays of
* js objects containing these data to a single callback method.
*/
@implementation BatchProductsRequestDelegate
@synthesize plugin, command;
- (void)productsRequest:(SKProductsRequest*)request didReceiveResponse:(SKProductsResponse*)response {
NSLog(@"InAppPurchase[objc]: productsRequest: didReceiveResponse:");
NSMutableArray *validProducts = [NSMutableArray array];
NSLog(@"InAppPurchase[objc]: Has %li validProducts", (unsigned long)[response.products count]);
for (SKProduct *product in response.products) {
NSLog(@"InAppPurchase[objc]: - %@: %@", product.productIdentifier, product.localizedTitle);
[validProducts addObject:
[NSDictionary dictionaryWithObjectsAndKeys:
NILABLE(product.productIdentifier), @"id",
NILABLE(product.localizedTitle), @"title",
NILABLE(product.localizedDescription), @"description",
NILABLE(product.localizedPrice), @"price",
nil]];
[self.plugin.list setObject:product forKey:[NSString stringWithFormat:@"%@", product.productIdentifier]];
}
NSArray *callbackArgs = [NSArray arrayWithObjects:
NILABLE(validProducts),
NILABLE(response.invalidProductIdentifiers),
nil];
CDVPluginResult* pluginResult =
[CDVPluginResult resultWithStatus:CDVCommandStatus_OK messageAsArray:callbackArgs];
NSLog(@"InAppPurchase[objc]: productsRequest: didReceiveResponse: sendPluginResult: %@", callbackArgs);
[self.plugin.commandDelegate sendPluginResult:pluginResult callbackId:self.command.callbackId];
[request release];
[self release];
}
- (void) dealloc {
[plugin release];
[command release];
[super dealloc];
}
@end
InAppPurchase.js
/**
* A plugin to enable iOS In-App Purchases.
*
* Copyright (c) Matt Kane 2011
* Copyright (c) Guillaume Charhon 2012
* Copyright (c) Jean-Christophe Hoelt 2013
*/
cordova.define("cordova/plugin/InAppPurchase", function(require, exports, module) {
var exec = function (methodName, options, success, error) {
cordova.exec(success, error, "InAppPurchase", methodName, options);
};
var log = function (msg) {
console.log("InAppPurchase[js]: " + msg);
};
var InAppPurchase = function() {
this.options = {};
};
// Error codes.
InAppPurchase.ERR_SETUP = 1;
InAppPurchase.ERR_LOAD = 2;
InAppPurchase.ERR_PURCHASE = 3;
InAppPurchase.prototype.init = function (options) {
this.options = {
ready: options.ready || function () {},
purchase: options.purchase || function () {},
restore: options.restore || function () {},
restoreFailed: options.restoreFailed || function () {},
restoreCompleted: options.restoreCompleted || function () {},
error: options.error || function () {}
};
var that = this;
var setupOk = function () {
log('setup ok');
that.options.ready();
// Is there a reason why we wouldn't like to do this automatically?
// YES! it does ask the user for his password.
// that.restore();
};
var setupFailed = function () {
log('setup failed');
options.error(InAppPurchase.ERR_SETUP, 'Setup failed');
};
exec('setup', [], setupOk, setupFailed);
};
/**
* Makes an in-app purchase.
*
* @param {String} productId The product identifier. e.g. "com.example.MyApp.myproduct"
* @param {int} quantity
*/
InAppPurchase.prototype.purchase = function (productId, quantity) {
quantity = (quantity|0) || 1;
var options = this.options;
var purchaseOk = function () {
log('Purchased ' + productId);
if (typeof options.purchase === 'function')
options.purchase(productId, quantity);
};
var purchaseFailed = function () {
var msg = 'Purchasing ' + productId + ' failed';
log(msg);
if (typeof options.error === 'function')
options.error(InAppPurchase.ERR_PURCHASE, msg, productId, quantity);
};
return exec('purchase', [productId, quantity], purchaseOk, purchaseFailed);
};
/**
* Asks the payment queue to restore previously completed purchases.
* The restored transactions are passed to the onRestored callback, so make sure you define a handler for that first.
*
*/
InAppPurchase.prototype.restore = function() {
return exec('restoreCompletedTransactions', []);
};
/**
* Retrieves localized product data, including price (as localized
* string), name, description of multiple products.
*
* @param {Array} productIds
* An array of product identifier strings.
*
* @param {Function} callback
* Called once with the result of the products request. Signature:
*
* function(validProducts, invalidProductIds)
*
* where validProducts receives an array of objects of the form:
*
* {
* id: "<productId>",
* title: "<localised title>",
* description: "<localised escription>",
* price: "<localised price>"
* }
*
* and invalidProductIds receives an array of product identifier
* strings which were rejected by the app store.
*/
InAppPurchase.prototype.load = function (productIds, callback) {
var options = this.options;
if (typeof productIds === "string") {
productIds = [productIds];
}
if (!productIds.length) {
// Empty array, nothing to do.
callback([], []);
}
else {
if (typeof productIds[0] !== 'string') {
var msg = 'invalid productIds given to store.load: ' + JSON.stringify(productIds);
log(msg);
options.error(InAppPurchase.ERR_LOAD, msg);
return;
}
log('load ' + JSON.stringify(productIds));
var loadOk = function (array) {
log("loadOk()");
var valid = array[0];
var invalid = array[1];
log('load ok: { valid:' + JSON.stringify(valid) + ' invalid:' + JSON.stringify(invalid) + ' }');
callback(valid, invalid);
};
var loadFailed = function (errMessage) {
log('load failed: ' + errMessage);
options.error(InAppPurchase.ERR_LOAD, 'Failed to load product data: ' + errMessage);
};
exec('load', [productIds], loadOk, loadFailed);
}
};
/* This is called from native.*/
InAppPurchase.prototype.updatedTransactionCallback = function (state, errorCode, errorText, transactionIdentifier, productId, transactionReceipt) {
// alert(state);
switch(state) {
case "PaymentTransactionStatePurchased":
this.options.purchase(transactionIdentifier, productId, transactionReceipt);
return;
case "PaymentTransactionStateFailed":
this.options.error(errorCode, errorText);
return;
case "PaymentTransactionStateRestored":
this.options.restore(transactionIdentifier, productId, transactionReceipt);
return;
}
};
InAppPurchase.prototype.restoreCompletedTransactionsFinished = function () {
this.options.restoreCompleted();
};
InAppPurchase.prototype.restoreCompletedTransactionsFailed = function (errorCode) {
this.options.restoreFailed(errorCode);
};
/*
* This queue stuff is here because we may be sent events before listeners have been registered. This is because if we have
* incomplete transactions when we quit, the app will try to run these when we resume. If we don't register to receive these
* right away then they may be missed. As soon as a callback has been registered then it will be sent any events waiting
* in the queue.
*/
InAppPurchase.prototype.runQueue = function () {
if(!this.eventQueue.length || (!this.onPurchased && !this.onFailed && !this.onRestored)) {
return;
}
var args;
/* We can't work directly on the queue, because we're pushing new elements onto it */
var queue = this.eventQueue.slice();
this.eventQueue = [];
args = queue.shift();
while (args) {
this.updatedTransactionCallback.apply(this, args);
args = queue.shift();
}
if (!this.eventQueue.length) {
this.unWatchQueue();
}
};
InAppPurchase.prototype.watchQueue = function () {
if (this.timer) {
return;
}
this.timer = window.setInterval(function () {
window.storekit.runQueue();
}, 10000);
};
InAppPurchase.prototype.unWatchQueue = function () {
if (this.timer) {
window.clearInterval(this.timer);
this.timer = null;
}
};
InAppPurchase.eventQueue = [];
InAppPurchase.timer = null;
module.exports = new InAppPurchase();
});