Javi Moreno Apuntes Fichas de Lectura Archivo Sobre mi
Blog Logo

Javi


18 minutos de lectura

El puerto espacial de Mos Eisley. No encontrarás nunca un lugar como éste tan lleno de maldad y vileza. Debemos cuidarnos.

Star Wars, Ben Kenobi llegando a Mos Eisley con Luke Skywalker

Para esta tercera entrega volvemos a basarnos en unos artículos de Rafa Aguilar (aka @rais38), recien publicados en Objective-C.es, donde primero nos explica las categorías y tipos de In-App Purchases y después nos enseña con un ejemplo lo sencillo que es incorporar a una aplicación esta excelente fuente de ingresos.
Nosotros aquí vamos a ver como simplifica CargoBay el uso de IAP en nuestras aplicaciones y también a verificar si la compra se ha realizado correctamente por el método más seguro: un servidor con Helios.

He de reconocer que a medida que he ido avanzando en el análisis de Helios he ido apreciando la bien que esta planteado y lo que puede suponer para un desarrollador que se quiera aventurar en la creación de su propio backend. Cierto es que la primera parte, la que replica el modelo Core Data en el servidor me parece que está todavía un poco floja, así como la sincronización entre los dispositivos y el backend. El soporte para notificaciones push está bien, todavía le queda camino que recorrer pero es un buen punto de partida si quieres tener tu propio gestor de notificaciones. Pero con CargoBay y Venice, la gestión de las compras dentro de la aplicación se simplifican una barbaridad.

CargoBay es una pequeña librería de Mattt Thompson (del que no hemos hablado prácticamente nada en este blog) que facilita la gestión de estas transacciones al reducir a unos pocos métodos con bloques la recuperación de productos, comprobación del estado de la compra así como la verificación del recibo siguiendo las recomendaciones de Apple para evitar los fraudes en este tipo de compras.

Venice, es la gema que incluye Helios para realizar la verificación del recibo en servidor (otra recomendación de Apple para evitar fraudes). La otra funcionalidad que ofrece esta gema es devolver un listado de identificadores de IAP, esto será muy útil cuando queramos cambiar la oferta de productos en nuestra aplicación sin tener que actualizar la aplicación vía iTunes Connect. Para poder utilizar esta funcionalidad, nuestra aplicación debe estar preparada para trabajar con todos los productos que le vayan a llegar por este servicio.

Con todo lo que ya sabemos gracias a Rafa, vamos a crear una aplicación que nos permita contratar los servicios de los piratas y cazarecompensas que habitan Mos Eisley. Sabemos que nadie es de fiar en este lugar así que mejor que incluyamos un sistema de verificación de las compras o nuestro jefe nos terminará dando de comer a un sarlacc (algo muy doloroso ya que recordemos que su digestión dura más de mil años).

Para probar algunas bondades de CargoBay vamos a hacer lo siguiente: En iTunes Connect vamos a crear las siguientes IAP tal y como nos ha contado Rafa:

Producto | Identificador | ----------------- | ----------------------------------------- | Han Solo | com.cytdevteam.MosEisley.HanSolo Chewbacca | com.cytdevteam.MosEisley.Chewbacca Millennium Falcon | com.cytdevteam.MosEisley.MillenniumFalcon Modal Nodes | com.cytdevteam.MosEisley.ModalNodes

En nuestro repositorio de productos de Helios vamos a crear estos cuatro productos y dos más:

Producto | Identificador | ----------------- | ----------------------------------------- | Han Solo | com.cytdevteam.MosEisley.HanSolo Chewbacca | com.cytdevteam.MosEisley.Chewbacca Millennium Falcon | com.cytdevteam.MosEisley.MillenniumFalcon Modal Nodes | com.cytdevteam.MosEisley.ModalNodes Greedo | com.cytdevteam.MosEisley.Greedo Boba Fett | com.cytdevteam.MosEisley.BobaFett

Si echamos un vistazo a la documentación de Helios, veremos que los únicos métodos que ofrece para In-App Purchases son un GET de productos y un POST para comprobar recibos... ¿Cómo grabamos entonces los productos en la tabla? pues como graban los hombres, con SQL directamente sobre la base de datos.

Creamos un nuevo fichero HeliosTasks.rake en nuestro proyecto para poder lanzarlo tanto en local como en servidor de forma manual y escribimos lo siguiente:

namespace :HeliosTasks do
  desc "TODO"
  task :loadIdentifiers => :environment do
    require 'pg'

    def connect(db, user, pw)
      PGconn.new('localhost', 5432, '', '', db, user, pw) 
    end

    def populate_products(conn)
      sql = "DELETE FROM in_app_purchase_products;  -- empty contents of table
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (1, 'com.cytdevteam.MosEisley.HanSolo', 'Consumable', 'Han Solo', 'Han Solo The One And Only', 0.99, 'USD', 't');
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (2, 'com.cytdevteam.MosEisley.Chewbacca', 'Consumable', 'Chewbacca', 'Chewbacca', 0.99, 'USD', 't');
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (3, 'com.cytdevteam.MosEisley.MillenniumFalcon', 'Consumable', 'Millennium Falcon', 'Millennium Falcon', 4.99, 'USD', 't');
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (4, 'com.cytdevteam.MosEisley.ModalNodes', 'Consumable', 'Modal Nodes', 'A real Modal Nodes` gig', 4.99, 'USD', 't');
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (5, 'com.cytdevteam.MosEisley.Greedo', 'Consumable', 'Greedo', 'A coward that shot first', 4.99, 'USD', 't');
             INSERT INTO in_app_purchase_products (id, product_identifier, type, title, description, price, price_locale, is_enabled) 
                           VALUES (6, 'com.cytdevteam.MosEisley.BobaFett', 'Consumable', 'Boba Fett', 'He is no good to me dead', 4.99, 'USD', 't');"
      conn.exec(sql)
    end

    begin
      conn = connect('databaseName','username','password')
      puts "Connected to #{conn.db} at #{conn.host}"
      populate_products(conn)  
    rescue PGError=>e 
      puts "Oh Oh!", e
    ensure
      conn.close unless conn.nil?
      puts "Connection closed" 
    end
  end
end

No creo que os cueste mucho ver lo que hace: establece una conexión con la base de datos y a continuación borra e inserta seis registros. Si hubiera algún error saldría por la consola.

Si ahora escribimos en un navegador http://localhost:3000/products/identifiers

veríamos algo como esto:

Ya hemos creado todas las IAP en iTunes Connect (que coñazo) y hemos insertado los productos que vamos a ofrecer en nuestro servidor. Es el momento de liarnos la manta a la cabeza con la aplicación.

Nos vamos a basar en la plantilla de Master-Detail con ARC, Storyboards y Core Data. La primera lista serán las compras que hayamos realizado. En Storyboard añadiremos un nuevo navigation controller con un UITableViewController donde mostraremos los productos que se pueden comprar a través del App Store.

Si a estas alturas todavía no usas CocoaPods deberías hacerlo, es la forma más fácil de gestionar las librerías de terceros que usas en tus aplicaciones. En este caso, nuestro podfile incluirá AFNetworking, CargoBay y NSData+Base64:

platform :ios, '5.0'
pod 'AFNetworking', '~> 1.2'
pod 'CargoBay', '~> 0.3.2'
pod 'NSData+Base64', '~> 1.0.0'

Un pequeño expositor.

Ya sabemos que lo primero que hay que hacer es comprobar si nuestra aplicación tiene permiso para hacer compras dentro de la aplicación. En caso afirmativo podremos acceder a nuestros servidores para recuperar la información de los productos que tenemos a la venta:

- (void)viewDidLoad
{
    [super viewDidLoad];

    // Comprobamos si hay alguna restricción configurada en el device respecto a las In-App Purchases.
    if ([SKPaymentQueue canMakePayments]) {
        NSLog(@"Puedo hacer pagos In-App");
        [self getProductsInStoreKitDirectlyFromHelios];
    }
    else {
        NSLog(@"Control parental activado");
    }
}

Para cargar el UITableView del viewController de productos necesitamos un array de productos. Toda la información del producto tal y como está en iTunes Connect la podemos obtener a través de StoreKit pero CargoBay nos proporciona unos métodos con bloques que recuperan está información. Podemos hacerlo de dos formas: recuperando primero la lista de identificadores y después pasándole esta lista al método correspondiente:

- (void)getProductsIdentifiersInHelios
{
    NSURL *url = [NSURL URLWithString:@"http://localhost:3000/products/identifiers/"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    
    [AFJSONRequestOperation JSONRequestOperationWithRequest:request success:^(NSURLRequest *request, NSHTTPURLResponse *response, id JSON) {
        [self getProductsInStoreKitFromArray:JSON];
    } failure:^(NSURLRequest *request, NSHTTPURLResponse *response, NSError *error, id JSON) {
        NSLog(@"Error: %@", [error description]);
    }];
}
    
- (void)getProductsInStoreKitFromArray:(NSArray *)array
{
    [[CargoBay sharedManager] productsWithIdentifiers:[NSSet setWithArray:array] success:^(NSArray *products, NSArray *invalidIdentifiers) {
        if (!_productsArray) {
            _productsArray = [[NSMutableArray alloc] initWithCapacity:0];
        }
        NSLog(@"Products: %@", products);
        NSLog(@"Invalid Identifiers: %@", invalidIdentifiers);
        _productsArray = [NSMutableArray arrayWithArray:products];
        [self.tableView reloadData];
    } failure:^(NSError *error) {
        NSLog(@"Error: %@", error);
    }];
}

O bien, si tenemos la certeza que el servicio ya nos devuelve los datos en una array únicamente con los identificadores (que es como espera CargoBay que se lo pasemos), podemos hacerlo todo en un único método:

- (void)getProductsInStoreKitDirectlyFromHelios
{
    
    NSURL *url = [NSURL URLWithString:@"http://localhost:3000/products/identifiers/"];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [[CargoBay sharedManager] productsWithRequest:request success:^(NSArray *products, NSArray *invalidIdentifiers) {
        NSLog(@"Products: %@", products);
        NSLog(@"Invalid Identifiers: %@", invalidIdentifiers);
        _productsArray = [NSMutableArray arrayWithArray:products];
        [self.tableView reloadData];
    } failure:^(NSError *error) {
        NSLog(@"Error: %@", [error description]);
    }];
}

En cualquiera de los dos casos, el resultado del log será el siguiente:

2013-05-15 16:14:48.275 MosEisley[3242:c07] Products: (
    "<SKProduct: 0x77ad230>",
    "<SKProduct: 0x82a5ab0>",
    "<SKProduct: 0x8267f70>",
    "<SKProduct: 0x8263400>"
)
2013-05-15 16:14:48.275 MosEisley[3242:c07] Invalid Identifiers: (
    "com.cytdevteam.MosEisley.Greedo",
    "com.cytdevteam.MosEisley.BobaFett"
)

Es decir, hemos recuperado seis registros de nuestro servidor de los cuales cuatro son productos correctos en iTunes Connect y dos no lo son. Solo mostraremos los productos correctos:

#pragma mark - Table view data source
 
 - (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView
 {
     // Return the number of sections.
     return 1;
 }
 
 - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section
 {
     // Return the number of rows in the section.
     return [_productsArray count];
 }
 
 - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
 {
     static NSString *CellIdentifier = @"Cell";
     UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier forIndexPath:indexPath];
     
     // Configure the cell...
     SKProduct *product = [_productsArray objectAtIndex:indexPath.row];
     
     cell.textLabel.text = product.localizedTitle;
     cell.detailTextLabel.text = product.localizedDescription;
     return cell;
 }

Esta será, más o menos, la imagen de nuestro expositor:

Para realizar las compras, CargoBay no tiene ninguna utilidad desarrollada por el momento, la única diferencia con el tutorial de Rafa es que el observer no es necesario incluirlo aquí ya que lo vamos a hacer en el (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions para que gestiones no solo estas compras si no también las compras que pudieran haber quedado pendientes al cerrar la aplicación:

#pragma mark - Table view delegate
 
 - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
 {
     // Añadimos el producto que recibimos en el método delegado productsRequest:didReceiveResponse:
     SKPayment *pago = [SKPayment paymentWithProduct:[_productsArray objectAtIndex:indexPath.row]];
     // Nos añadimos a nosotros mismos como observadores de la transacción.
 //    [[SKPaymentQueue defaultQueue] addTransactionObserver:self];
     [[SKPaymentQueue defaultQueue] addPayment:pago];
 }

Que no se pierda ni un recibo.

Tal y como acabamos de comentar, el observer de la cola de pagos lo vamos a incluir en (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions para que no se pierda ni una sola operación. Además incluimos el bloque que se llamará cada vez que haya alguna actualización de una transacción:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    // Payment Queue Observation with CargoBay
    [[CargoBay sharedManager] setPaymentQueueUpdatedTransactionsBlock:^(SKPaymentQueue *queue, NSArray *transactions) {
        NSLog(@"Updated Transactions: %@", transactions);
        for (SKPaymentTransaction *transaction in transactions)
        {
            switch (transaction.transactionState)
            {
                case SKPaymentTransactionStatePurchased:
                    [self oneStepVerification:transaction];
                    break;
                case SKPaymentTransactionStateFailed:
                         // TODO
                    break;
                case SKPaymentTransactionStateRestored:
                         // TODO
                    break;
                default:
                    break;
            }
        }
    }];
    
    [[SKPaymentQueue defaultQueue] addTransactionObserver:[CargoBay sharedManager]];
    
    // Override point for customization after application launch.
    ...
}

Cuando la transacción pase a un estado de purchased es cuando deberemos realizar las acciones derivadas de la compra, antes de hacer nada es cuando deberíamos asegurarnos de que el recibo es verdadero. Las dos opciones que tenemos son verificar dentro de la propia aplicación accediendo a un servicio de Apple o llamar a un servicio nuestro que realice ese mismo acceso. Para el primer caso, CargoBay ya nos proporciona un método que realiza esa verificación:

- (void)oneStepVerification:(SKPaymentTransaction *)transaction
{
    [[CargoBay sharedManager] verifyTransaction:transaction password:nil success:^(NSDictionary *receipt) {
        NSLog(@"Receipt: %@", receipt);
        [self serverSideVerification:transaction];
    } failure:^(NSError *error) {
        NSLog(@"Error %d (%@)", [error code], [error localizedDescription]);
    }];
}

Es de sobra conocido que tanto las IAP son fácilmente pirateables y aunque la verificación desde la propia aplicación aumenta la seguridad de nuestras ventas, también son conocidos los casos en los que esta verificación también ha sido hackeada. La otra utilidad de Venice es la realización de esta misma verificación en nuestro servidor:

- (void)serverSideVerification:(SKPaymentTransaction *)transaction
{
    NSURL *url = [NSURL URLWithString:@"http://localhost:3000/receipts/verify"];
    NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:url];
    [request setHTTPMethod:@"POST"];
    
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    NSString *appFile = [documentsDirectory stringByAppendingPathComponent:@"TransactionReceipt"];
    [transaction.transactionReceipt writeToFile:appFile atomically:YES];
    
    NSString *params = [NSString stringWithFormat:@"receipt-data=%@", [transaction.transactionReceipt base64EncodedString]];
    NSData *httpBody = [params dataUsingEncoding:NSUTF8StringEncoding];
    [request setHTTPBody:httpBody];
    
    [NSURLConnection sendAsynchronousRequest:request queue:[NSOperationQueue mainQueue] completionHandler:^(NSURLResponse *response, NSData *data, NSError *error) {
        NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response;
        if (httpResponse.statusCode == 200 || httpResponse.statusCode == 203) {
            id receipt = [NSJSONSerialization JSONObjectWithData:data
                                                         options:0
                                                           error:nil];
            NSLog(@"Received receipt: %@", receipt);
            [[SKPaymentQueue defaultQueue] finishTransaction: transaction];
        } else {
            NSLog(@"Body: %@", [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
            NSLog(@"ERROR: %@", error);
        }
    }];
}

En el ejemplo de nuestra aplicación realizamos primero la verificación desde nuestra aplicación y posteriormente nos aseguramos repitiendo la verificación en nuestro servidor. Quizá sería más práctico hacerlo al revés: primero en nuestro servidor y si, por algún motivo, no hemos podido realizar la verificación hacerla desde nuestra aplicación como segunda opción.

Ah, podemos dormir tranquilos. La verificación desde nuestro servidor deja registro para que podamos hacernos una idea del volumen de nuestras compras... y a lo mejor sorprender a algún tunante:

A partir de aquí, si la compra ha sido correcta, podemos insertar el registro en nuestra base de datos que le informa a la aplicación que la compra ha sido registrada con éxito. En el ejemplo de Rafa, en este momento habría que descargar la canción.

Por esta vez nada más, si habéis leído los anteriores notaréis que esta vez estoy más entusiasmado con la funcionalidad de Helios. No me he pegado mucho con StoreKit, tan solo con la versión pro de una aplicación, pero la verdad es que ahora si que tengo claro que usaré CargoBay y con toda seguridad haré la verificación en servidor con Venice... aunque haya que tocar algo el código base.

Ojito con las dependencias. No se que versión de Helios, CargoBay y AFNetworking estaréis utilizando pero se nota que estos frameworks se están tocando bastante estos días. Yo he tenido algún problemita que otro a la hora de preparar esta entrada. Nada que un par de horas de cabezazos en la pared no solucionen... ;-)