iOS でのアプリ内購入メカニズムをテストするために、概念実証の PhoneGap アプリを作成しました。アプリは Phonegap 2.9.0 に基づいており、この InAppPurchase プラグインを使用し、プラグインの使用方法を説明するこのチュートリアルに大まかに基づいています。
問題は、Apple サーバーから InApp Purchase データを正常に受信したときに、Objective-C プラグインによって Javascript コールバック関数が実行されていないことです。JSが実行されない理由がわからないので、誰かが問題を見つけられることを願っています...?
XCode 4.6.3 を使用して iPhone 4S でアプリを実行すると、InApp Purchase アイテムの製品データを受信すると、StoreKit API がproductsRequest
成功コールバックを非同期に呼び出すまで、すべてが機能します。XCode ログ ウィンドウに出力される 213 行目のステートメントInAppPurchase.m
の出力を確認できます。これには、InApp Purchase アイテムの正しい詳細が含まれています。その後の行では、128 行目で定義され、140 行目に渡された Javascript 成功コールバックが実行されますが、129 行目のログ出力は XCode ログ ウィンドウに表示されません。NSLog
callbackArgs
InAppPurchase.js
XCode でブレークポイントを使用して Objective-C をステップ実行すると、callbackId
変数に適切な値があることがわかり、Cordova コードにステップスルーself.plugin.commandDelegate
して、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();
});