1

A client has brought an existing app to me, and we've just released a new version including Crittercism for crash reports.

Since release, we've been getting a load of crash reports as below. I believe that the crash is caused by the delegate of the SKProductRequest being released too soon, so I'm not looking for the answer to why the crash is happening - that's already been answered elsewhere on StackOverflow.

My problem is that I can't replicate the bug. I've tried numerous devices, and different versions of iOS. From Crittercism, the crash is happening on mainly up to date devices, and a range of iPhone, iPod and iPad - so it is not one particular type of device, but I still can't make it happen. I've downloaded the Lite version, and from there, bought the Full version - it all works perfectly.

My question therefore is does anyone have any idea how I can make it happen on my devices so that I can fix it?!

libobjc.A.dylib 0x37393f78 objc_msgSend + 15
StoreKit 0x37bc3a4f -[SKProductsRequest handleFinishResponse:returningError:] + 142
StoreKit 0x37bc4dc7 -[SKRequest _requestFinishedNotification:] + 210
Foundation 0x319624ff __57-[NSNotificationCenter addObserver:selector:name:object:]_block_invoke_0 + 18
CoreFoundation 0x31027547 ___CFXNotificationPost_block_invoke_0 + 70
CoreFoundation 0x30fb3097 _CFXNotificationPost + 1406
Foundation 0x318d63eb -[NSNotificationCenter postNotificationName:object:userInfo:] + 66
AppSupport 0x314eeba3 -[CPDistributedNotificationCenter deliverNotification:userInfo:] + 62
AppSupport 0x314f010b _CPDNDeliverNotification + 290
AppSupport 0x314ee99b _XDeliverNotification + 170
AppSupport 0x314e3b11 migHelperRecievePortCallout + 172
CoreFoundation 0x3102f523 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ + 38
CoreFoundation 0x3102f4c5 __CFRunLoopDoSource1 + 140
CoreFoundation 0x3102e313 __CFRunLoopRun + 1370
CoreFoundation 0x30fb14a5 CFRunLoopRunSpecific + 300
CoreFoundation 0x30fb136d CFRunLoopRunInMode + 104
GraphicsServices 0x3302b439 GSEventRunModal + 136
UIKit 0x30714cd5 UIApplicationMain + 1080
MyAppLite 0x000fc7c3 main (main.m:13)

The culprit is bound to be in here somewhere, but I still can't make it crash on my devices, or in the simulator:

#import "InAppPurchaseViewController.h"

#define INDICATOR_Y 150.0f
#define INDICATOR_MOVE_Y 300.0f
#define PRODUCT_LABEL_Y 150.0f
#define PURCHASE_BUTTON_Y 190.0f
#define RESTORE_BUTTON_Y 240.0f

@interface InAppPurchaseViewController (Private)
- (void)updateUIToDefaultState;
@end

@implementation InAppPurchaseViewController

- (void) dealloc {
    [productLabel release];
    [purchaseButton release];
    [indicatorView release];
    [super dealloc];
}

#pragma mark -
#pragma mark UIViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    self.title = @"...";
    self.view.backgroundColor = [UIColor blackColor];
    self.tableView.backgroundColor = [UIColor blackColor];
    self.tableView.separatorStyle = UITableViewCellSeparatorStyleNone;
    self.tableView.scrollEnabled = NO;
    self.tableView.indicatorStyle = UIScrollViewIndicatorStyleWhite;

    UIImageView *headerImageView = [[[UIImageView alloc] initWithImage:[UIImage imageNamed:@"upgrade-header.png"]] autorelease];
    UIView *headerView = [[[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, self.view.bounds.size.width, headerImageView.image.size.height + 200.0)] autorelease];
    [headerView addSubview:headerImageView];

    indicatorView = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleWhiteLarge];
    indicatorView.hidesWhenStopped = YES;
    [indicatorView startAnimating];
    [headerView addSubview:indicatorView];

    productLabel = [[UILabel alloc] initWithFrame:CGRectMake(0.0f, 0.0f, 300.0f, 0.0f)];
    productLabel.text = @"...";
    productLabel.hidden = YES;
    productLabel.font = [UIFont systemFontOfSize:18.0f];
    productLabel.textColor = [UIColor whiteColor];
    productLabel.backgroundColor = [UIColor blackColor];
    [productLabel sizeToFit];
    [headerView addSubview:productLabel];

    purchaseButton = [[UIButton buttonWithType:UIButtonTypeCustom] retain];
    purchaseButton.titleLabel.font = [UIFont boldSystemFontOfSize:20.0f];
    UIImage *bgImage = [UIImage imageNamed:@"btn_purchase.png"];
    UIImage *buttonImage = [bgImage stretchableImageWithLeftCapWidth:(bgImage.size.width / 2.0f) - 1 topCapHeight:0.0f];
    [purchaseButton setBackgroundImage:buttonImage forState:UIControlStateNormal];
    [purchaseButton setTitle:@"Purchase" forState:UIControlStateNormal];
    [purchaseButton addTarget:self action:@selector(purchaseClicked:) forControlEvents:UIControlEventTouchUpInside];
    purchaseButton.frame = CGRectMake(0.0f, 0.0f, 300.0f, buttonImage.size.height);
    purchaseButton.hidden = YES;
    [self.view addSubview:purchaseButton];

    restoreButton = [[UIButton buttonWithType:UIButtonTypeCustom] retain];
    restoreButton.enabled = NO;
    restoreButton.titleLabel.font = [UIFont boldSystemFontOfSize:18.0f];
    [restoreButton setBackgroundImage:buttonImage forState:UIControlStateNormal];
    [restoreButton setTitle:@"Restore Purchases" forState:UIControlStateNormal];
    [restoreButton addTarget:self action:@selector(restoreClicked:) forControlEvents:UIControlEventTouchUpInside];
    restoreButton.frame = CGRectMake(0.0f, 0.0f, 300.0f, buttonImage.size.height);
    [self.view addSubview:restoreButton];

    headerImageView.center = headerView.center;
    headerImageView.frame = CGRectMake(headerImageView.frame.origin.x, 0.0f, headerImageView.frame.size.width, headerImageView.frame.size.height);

    indicatorView.center = headerView.center;
    indicatorView.frame = CGRectMake(indicatorView.frame.origin.x, INDICATOR_Y, indicatorView.frame.size.width, indicatorView.frame.size.height);

    productLabel.center = headerView.center;
    productLabel.frame = CGRectMake(productLabel.frame.origin.x, PRODUCT_LABEL_Y, productLabel.frame.size.width, productLabel.frame.size.height);

    purchaseButton.center = headerView.center;
    purchaseButton.frame = CGRectMake(purchaseButton.frame.origin.x, PURCHASE_BUTTON_Y, purchaseButton.frame.size.width, purchaseButton.frame.size.height);

    restoreButton.center = headerView.center;
    restoreButton.frame = CGRectMake(restoreButton.frame.origin.x, RESTORE_BUTTON_Y, restoreButton.frame.size.width, restoreButton.frame.size.height);

    self.tableView.tableHeaderView = headerView;

    [self performSelectorInBackground:@selector(retrieveProductDetails:) withObject:nil];
}

#pragma mark -
#pragma mark AppStoreServiceDelegate

- (void)productDetailsRequestSucceededWithResponse:(SKProductsResponse *)response {
    if (response.products.count == 1) {
        [self performSelectorOnMainThread:@selector(productDetailsRetrieved:) withObject:[response.products objectAtIndex:0] waitUntilDone:NO];    
    } else {
        NSString *message = @"Unable to retrieve product details: No valid product to purchase. Please contact support.";
        [self performSelectorOnMainThread:@selector(appStoreRequestFailed:) withObject:message waitUntilDone:NO];
    }
}

- (void)productDetailsRequestFailedWithError:(NSError *)error {
    NSString *message = [NSString stringWithFormat:@"Unable to retrieve product details: %@", [error localizedDescription]];
    [self performSelectorOnMainThread:@selector(appStoreRequestFailed:) withObject:message waitUntilDone:NO];
}

- (void)transactionSucceededForProductId:(NSString *)productId {
    [self performSelectorOnMainThread:@selector(purchaseCompleted:) withObject:productId waitUntilDone:NO];
}

- (void)transactionFailedWithReason:(NSString *)reason {
    NSString *message = [NSString stringWithFormat:@"Sorry, your purchase could not be completed: %@", reason];
    [self performSelectorOnMainThread:@selector(appStoreRequestFailed:) withObject:message waitUntilDone:NO];
}

- (void)transactionCancelled {
    [self performSelectorOnMainThread:@selector(updateUIToDefaultState) withObject:nil waitUntilDone:NO];
}

#pragma mark -
#pragma mark UIAlertViewDelegate (purchase failed)
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex {
    [self.navigationController popViewControllerAnimated:YES];
}

#pragma mark -
#pragma mark Private button callbacks

- (void)purchaseClicked:(UIButton *)clicked {
    clicked.enabled = NO;
    [indicatorView startAnimating];
    indicatorView.frame = CGRectMake(indicatorView.frame.origin.x, INDICATOR_MOVE_Y, indicatorView.frame.size.width, indicatorView.frame.size.height);
    [self performSelectorInBackground:@selector(purchaseProduct:) withObject:nil];
}

- (void)restoreClicked:(UIButton *)clicked {
    clicked.enabled = NO;
    [indicatorView startAnimating];
    indicatorView.frame = CGRectMake(indicatorView.frame.origin.x, INDICATOR_MOVE_Y, indicatorView.frame.size.width, indicatorView.frame.size.height);
    [self performSelectorInBackground:@selector(restoreProducts:) withObject:nil];
}

#pragma mark -
#pragma mark Private

- (void)showPurchaseDetailsWithName:(NSString *)productName price:(NSString *)price {
    self.title = productName;
    productLabel.text = [NSString stringWithFormat:@"%@, %@", productName, price];
    [productLabel sizeToFit];
    productLabel.center = self.tableView.tableHeaderView.center;
    [self updateUIToDefaultState];
}

- (void)productDetailsRetrieved:(SKProduct *)productDetails {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [indicatorView stopAnimating];
    [self showPurchaseDetailsWithName:productDetails.localizedTitle price:[[AppStoreService sharedAppStoreService] formatPrice:productDetails]];
    [pool drain];
}

- (void)purchaseCompleted:(NSString *)productId  {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [indicatorView stopAnimating];
    [[AppStoreService sharedAppStoreService] setPurchasedFullEdition:YES];
    [self.navigationController popViewControllerAnimated:YES];
    [pool drain];
}

- (void)purchaseProduct:(id)ignored {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [[AppStoreService sharedAppStoreService] purchaseProducts:[NSSet setWithObject:[[AppStoreService sharedAppStoreService] inAppProductIdentifierForEdition]] notifyingDelegate:self];
    [pool drain];
}

- (void)restoreProducts:(id)ignored {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [[AppStoreService sharedAppStoreService] retoreCompletedTransactionsNotifyingDelegate:self];
    [pool drain];
}

- (void)appStoreRequestFailed:(NSString *)reason {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [[[[UIAlertView alloc] initWithTitle:@"Purchase Error" message:reason delegate:self cancelButtonTitle:@"OK" otherButtonTitles:nil] autorelease] show];
    [self updateUIToDefaultState];
    [pool drain];
}

- (void)retrieveProductDetails:(id)ignored {
    NSAutoreleasePool *pool = [[NSAutoreleasePool alloc] init];
    [[AppStoreService sharedAppStoreService] requestDetailsOfProducts:[NSSet setWithObject:[[AppStoreService sharedAppStoreService] inAppProductIdentifierForEdition]] notifyingDelegate:self];
    [pool drain];
}

- (void)updateUIToDefaultState {
    [indicatorView stopAnimating];
    productLabel.hidden = NO;
    purchaseButton.hidden = NO;
    restoreButton.enabled = YES;
    purchaseButton.enabled = YES;
}

@end

Here's the AppStoreService.m

//  AppStoreService.m
    #import "AppStoreService.h"
    #import "SynthesizeSingleton.h"
    #import "DataService.h"
    #import "ConfigService.h"

    #pragma mark -
    #pragma mark Private internal app store delegates

    @implementation AppStoreProductRequestDelegate

    @synthesize delegate = _delegate;

    - (void)productsRequest:(SKProductsRequest *)request didReceiveResponse:(SKProductsResponse *)response {
        //NSLog(@"Got response... %@", response);
        [request release];
        if (self.delegate) {
            [self.delegate productDetailsRequestSucceededWithResponse:response];
        }
    }

    - (void)request:(SKRequest *)request didFailWithError:(NSError *)error {
        //NSLog(@"Got error... %@", error);
        [request release];
        if (self.delegate) {
            [self.delegate productDetailsRequestFailedWithError:error];
        }
    }

    @end

    @implementation AppStoreTransactionObserver

    @synthesize delegate = _delegate;

    - (void)paymentQueue:(SKPaymentQueue *)queue updatedTransactions:(NSArray *)transactions {
        for (SKPaymentTransaction *transaction in transactions) {
            switch (transaction.transactionState) {
                case SKPaymentTransactionStatePurchased: { [self completeTransaction:transaction]; break; }
                case SKPaymentTransactionStateFailed: { [self failedTransaction:transaction]; break; }
                case SKPaymentTransactionStateRestored: {[self restoreTransaction:transaction]; break; }
                default: break;
            }
        }
    }

    - (void)paymentQueue:(SKPaymentQueue *)queue restoreCompletedTransactionsFailedWithError:(NSError *)error {
        if (self.delegate) {
            [self.delegate transactionFailedWithReason:[NSString stringWithFormat:@"Purchase failed: %@", [error localizedDescription]]];
        }
    }

    - (void)paymentQueueRestoreCompletedTransactionsFinished:(SKPaymentQueue *)queue {
        //NSLog(@"paymentQueueRestoreCompletedTransactionsFinished:");
    }

    - (void)failedTransaction:(SKPaymentTransaction *)transaction {
        //NSLog(@"failedTransaction: %@", transaction);
        if (self.delegate) {
            if (transaction.error.code == SKErrorPaymentCancelled) {
                [self.delegate transactionCancelled];
            } else {
                [self.delegate transactionFailedWithReason:[NSString stringWithFormat:@"Purchase failed: %@", [transaction.error localizedDescription]]];
            }
        }
        [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }

    - (void)restoreTransaction:(SKPaymentTransaction *)transaction {
        //NSLog(@"restoreTransaction: %@", transaction);
        if (self.delegate) {
            [self.delegate transactionSucceededForProductId:transaction.originalTransaction.payment.productIdentifier];
        }
        [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }

    - (void)completeTransaction:(SKPaymentTransaction *)transaction {
        //NSLog(@"completeTransaction: %@", transaction);
        if (self.delegate) {
            [self.delegate transactionSucceededForProductId:transaction.payment.productIdentifier];
        }
        [[SKPaymentQueue defaultQueue] finishTransaction:transaction];
    }

    @end

    #pragma mark -
    #pragma mark AppStoreService

    @implementation AppStoreService

    SYNTHESIZE_SINGLETON_FOR_CLASS(AppStoreService);

    static NSString *kLPHasPurchasedFullEdition = @"kLPHasPurchasedFullEdition";

    - (AppStoreService *)init {
        if (self = [super init]) {
            productDetailsDelegate = [[AppStoreProductRequestDelegate alloc] init];
            appStoreObserver = [[AppStoreTransactionObserver alloc] init];
            [[SKPaymentQueue defaultQueue] addTransactionObserver:appStoreObserver];
        }
        return self;
    }

    - (void) dealloc {
        [[SKPaymentQueue defaultQueue] removeTransactionObserver:appStoreObserver];
        [productDetailsDelegate release];
        [appStoreObserver release];
        [super dealloc];
    }

    - (BOOL)hasPurchasedFullEdition {
        return [[NSUserDefaults standardUserDefaults] boolForKey:kLPHasPurchasedFullEdition];
    }

    - (void)setPurchasedFullEdition:(BOOL)purchased {
        //NSLog(@"Purchased? %d", purchased);
        [[NSUserDefaults standardUserDefaults] setBool:purchased forKey:kLPHasPurchasedFullEdition];
        [[ConfigService sharedConfigService] synchronizeConfig];
    }

    - (NSString *)inAppProductIdentifierForEdition {
        if ([[DataService sharedDataService] isLiteEdition]) {
            if ([DataService sharedDataService].isLanguageEdition) {
                // Note. Remove the "-" from language codes, e.g. Brazillian Portugese pt-br, as in-app purchase IDs cannot contain a hyphen.
                NSString *fixedCode = [[DataService sharedDataService].languageCode stringByReplacingOccurrencesOfString:@"-" withString:@""];
                return [NSString stringWithFormat:@"com.myBrokenApp.%@.AllCategories", fixedCode];
            } else {
                return @"com.myBrokenApp.AllCategories";
            }
        } else {
            @throw [NSException exceptionWithName:@"InvalidOperation" reason:@"An in app purchase product ID was requested for a non-lite version" userInfo:[NSDictionary dictionary]];
        }
    }

    - (NSString *)purchasedCategoryIdsWhereClause {
        // flirting & essentials for lite builds
        return ([DataService sharedDataService].isLiteEdition && ![self hasPurchasedFullEdition]) ? @"and c.category_id in (1,19)" : @" ";
    }

    - (NSString *)formatPrice:(SKProduct *)product {
        NSNumberFormatter *numberFormatter = [[NSNumberFormatter alloc] init];
        [numberFormatter setFormatterBehavior:NSNumberFormatterBehavior10_4];
        [numberFormatter setNumberStyle:NSNumberFormatterCurrencyStyle];
        [numberFormatter setLocale:product.priceLocale];
        NSString *currency = [numberFormatter stringFromNumber:product.price];
        [numberFormatter release];
        return currency;
    }

    - (void)requestDetailsOfProducts:(NSSet *)products notifyingDelegate:(id<AppStoreServiceDelegate>)delegate {
        //NSLog(@"Retrieving details of products: %@", products);
        SKProductsRequest *request = [[SKProductsRequest alloc] initWithProductIdentifiers:products];
        productDetailsDelegate.delegate = delegate;
        request.delegate = productDetailsDelegate;
        //NSLog(@"Starting request...");
        [request start];
    }

    - (void)purchaseProducts:(NSSet *)products notifyingDelegate:(id<AppStoreServiceDelegate>)delegate {
        //NSLog(@"Making in app purchase for products: %@", products);
        if ([SKPaymentQueue canMakePayments]) {
            appStoreObserver.delegate = delegate;
            for (NSString *productId in products) {
                [[SKPaymentQueue defaultQueue] addPayment:[SKPayment paymentWithProductIdentifier:productId]];
            }

        } else {
            [delegate transactionFailedWithReason:@"You are not permitted to make purchases."];
        }
    }

    - (void)retoreCompletedTransactionsNotifyingDelegate:(id<AppStoreServiceDelegate>)delegate {
        //NSLog(@"Restoring in-app purchases...");
        if ([SKPaymentQueue canMakePayments]) {
            appStoreObserver.delegate = delegate;
            [[SKPaymentQueue defaultQueue] restoreCompletedTransactions];
        } else {
            [delegate transactionFailedWithReason:@"You are not permitted to make purchases."];
        }
    }

    @end
siburb
  • 4,460
  • 1
  • 23
  • 34
  • Intermittent crashes are usually indicative of threading problems. I would double check your threading logic and make sure that is robust, chances are that would rectify your problem. – Stunner Aug 26 '12 at 05:04

2 Answers2

1

Whisk, you have posted some highly questionable code here. For one thing, you use SYNTHESIZE_SINGLETON_FOR_CLASS() and that means your AppStoreService dealloc method will never get invoked, and thus you have memory leaks in your app. Also, the calls to [request release] in productsRequest look very strange indeed. But, the specific problem you are having with SKProductsRequest seems to be related to a delegate that points back at one of your objects that is deallocated before SKProductsRequest invokes the callback. This indicates that you need to set the delegate to nil when the object that is acting as the delegate invokes its dealloc method. As for how to reproduce the problem, this one sounds very tricky because of the really odd object lifetimes you have setup in your code. I would suggest that you make use of an automated regression tests harness, but I am not sure it would even work to check object lifetimes in your app because of your use of a singleton. See What is so bad about singletons?

Community
  • 1
  • 1
MoDJ
  • 4,060
  • 1
  • 25
  • 59
  • I agree, and thanks for the notes. I've been trying not to rewrite all of the previous developer's code, but it may well be necessary. I'll vote you as the answer to this old question because we do seem to just have a case of rewrite the whole thing, but better! – siburb Jun 21 '13 at 01:07
1

I think your theory "the delegate of the SKProductRequest being released too soon" is probably correct. Have you tried quitting/killing the application in the middle of a purchase? (If the sandbox server is too fast (hah! That'll be the day), try inserting some breakpoints just before transaction finalization and manually terminate the app.) When your app is launched again and eventually registers an object with StoreKit as a transaction delegate, it'll get open transactions from previous runs, which uncovered a couple design mistakes and helped us fix a class of bugs around StoreKit transaction completion.

cbowns
  • 5,932
  • 4
  • 41
  • 63