Tulsa Dev Lunch iOS at Work

38
iOS at Work: Integrating iOS Apps with Back End Systems Matt Galloway (Freelance Mobile Developer Extraordinaire) Tulsa Dev Lunch February 13, 2013 Wednesday, February 13, 13

description

This the deck I used for a talk on integrating iOS into back office systems at the February 13, 2013 Tulsa Dev Lunch.

Transcript of Tulsa Dev Lunch iOS at Work

Page 1: Tulsa Dev Lunch iOS at Work

iOS at Work:Integrating iOS Apps with

Back End Systems

Matt Galloway

(Freelance Mobile Developer Extraordinaire)

Tulsa Dev LunchFebruary 13, 2013

Wednesday, February 13, 13

Page 2: Tulsa Dev Lunch iOS at Work

What about

Android?

Wednesday, February 13, 13

Page 3: Tulsa Dev Lunch iOS at Work

Android sucks.(Especially for business.)

Wednesday, February 13, 13

Page 4: Tulsa Dev Lunch iOS at Work

Most Consistent API

Consistent Hcxrdwcxre

--

Best Secur it .Y

1=eel G-ood Kumb·,cx Pseudo Open

Source-ness

Wednesday, February 13, 13

Page 5: Tulsa Dev Lunch iOS at Work

That said, most of what I’m gonna say about iOS applies to Android too.Meh.

Wednesday, February 13, 13

Page 6: Tulsa Dev Lunch iOS at Work

Think mobile!

Think now!

Wednesday, February 13, 13

Page 7: Tulsa Dev Lunch iOS at Work

Characteristics of Mobile

not a keyboard/mouse paradigm

unreliable low bandwidth high latency network connection

small screen

limited processing power and local storage

limited battery life

hostile work environment

untapped resources: camera(s), accelerometers, GPS, phone, speaker, mic, LED flash

Wednesday, February 13, 13

Page 8: Tulsa Dev Lunch iOS at Work

Wednesday, February 13, 13

Page 9: Tulsa Dev Lunch iOS at Work

Mobile web or die.

Wednesday, February 13, 13

Page 10: Tulsa Dev Lunch iOS at Work

Awesome Dashboard

"App"

Wednesday, February 13, 13

Natrve (iOS, Android, Blackberry, Windows Phone) App Window

Awesome Dashboard

"App"

Flll~d with a single We.bVIeW widget loaded

With your mobile web content.

Page 11: Tulsa Dev Lunch iOS at Work

Wednesday, February 13, 13

Page 12: Tulsa Dev Lunch iOS at Work

When the web won’t do.

Performance/Responsiveness/UX.

Complex local data store.

Network optional.

Hardware control.Sophisticated UI.

3D/accelerated graphics.

Wednesday, February 13, 13

Page 13: Tulsa Dev Lunch iOS at Work

How are enterprise mobile

apps different?

Complex local data stores.

Integration with back office

systems.

Wednesday, February 13, 13

Page 14: Tulsa Dev Lunch iOS at Work

,..__---------~----~~~

Mob.1le Inte_gr~t:1on J>os &- J>on'ts ,

OV\

Cove-r -the Y\et)

Wednesday, February 13, 13

0

I I I I I I I I I I I I I I I I I I I

Crf ~oLA he>. ve -t:.o) /

&-443 f>ov-ts B$Z>

I I I I I I I I I I I I

Page 15: Tulsa Dev Lunch iOS at Work

""' -+l a... . - • ~ (J H o ;.J (/) <U

:J cS ... >l-<Sw hZ.

_g ""'~ cu(/)V)

3~~ ""Q_.

_J (/) . ~h

l-I.

Protot _yp ·,ccxrash EV\terpr ·,se A rch.atecture

H H '"" ~ '"" • ~ LU

• LU (J (J

<(_ <(_ LU-tJ LU-tJ - ~ (U - ~ (U :s :s

h '"" h '"" 4- 4-'""~ '""~ r- r-r-~ r-~ (/) (/)

LU LU<!. LU LU<!. p!. z~ p!. z~

• •

Bus·aness Present~ t·aon D~t~ Access

Lo.9·ac

Wednesday, February 13, 13

sG.'-

D~t~

Stov-e

Page 16: Tulsa Dev Lunch iOS at Work

. -

...Q 0 ~

Wednesday, February 13, 13

EV\ tev-pv-·,se A v-c h ·,tectuv-e)(

D~t~ Access

-~

D~t~

Stov-e

Page 17: Tulsa Dev Lunch iOS at Work

Present~ t·aon )

Bus·aness Lo.9·ac;

&- D~ t~ Access

Wednesday, February 13, 13

D~t~

Stov-e

Page 18: Tulsa Dev Lunch iOS at Work

"EV\tev-pv-·ase" Av-ch.atectuv-e <;tu·ack·,e Mob·,r,z.cx t·aoV\ t=·,x

Present~ t·aon )

Bus·aness Lo.9·ac;

&- D~ t~ Access

Wednesday, February 13, 13

D~t~

Stov-e

. -

...Q 0 ~

'/ oLA'\\ V\eeO to •

bLA. ,\0 -t 'n \S.

Page 19: Tulsa Dev Lunch iOS at Work

Disclaimer: I’m

not .NET developer,

but I experimented a

little in college.

Wednesday, February 13, 13

Page 20: Tulsa Dev Lunch iOS at Work

In Visual Studio...1.) Create a Web Project

2.) Create a new Entity Model

3.) Reverse engineer Entity Model

from Database

4.) Create a WCF Data Service

5.) Add your Entity Model Class to

the Service class declaration

6.) Configure data access.

Wednesday, February 13, 13

Page 21: Tulsa Dev Lunch iOS at Work

http://www.hanselman.com/blog/CreatingAnODataAPIForStackOverflowIncludingXMLAndJSONIn30Minutes.aspxSource:

[JSONPSupportBehavior]public class Service : DataService<YourEnterpriseEntities>{ // This method is called only once to initialize service-wide policies. public static void InitializeService(DataServiceConfiguration config) { // config.SetEntitySetAccessRule("*", EntitySetRights.AllRead); config.SetEntitySetAccessRule("Locations", EntitySetRights.AllRead); config.SetEntitySetAccessRule("Customers", EntitySetRights.All); config.SetEntitySetAccessRule("SalesOrders", EntitySetRights.All); config.SetEntitySetAccessRule("Secrets", EntitySetRights.None); //Set a reasonable paging site config.SetEntitySetPageSize("*", 25); config.DataServiceBehavior.MaxProtocolVersion = DataServiceProtocolVersion.V2; }}

Wednesday, February 13, 13

Page 22: Tulsa Dev Lunch iOS at Work

http:LLyourhost.comLservice.svcLCustomers()

http:LLyourhost.comLservice.svcLCustomers(34)

http:LLyourhost.comLservice.svcLCustomers()? $filter=substringof('itactile',Name) or substringof('Galloway' ,ContactLastName)& $format=json

http:LLyourhost.comLservice.svcLCustomers(34)? $expand=Sales0rders$format=json

Wednesday, February 13, 13

Page 23: Tulsa Dev Lunch iOS at Work

http:LLyourhost.comLservice.svcL?~format=json

{ "d" • • { "Enti tySets" : [ "Batches", "Drawings", "DrawingTypes", "Elements", "ElernentAnswers", "ElernentAnswerPhotoes", "ElernentGroups", "Elernenticons", "ElernentQuestions", "ElernentRequirernents", "ElernentTypes", "LocationMetaDatas", "LocationMetaDataFields", "Locations", "PickListirnages", "Projects", "StoreAccesses", "sysdiagrarns", "TestTables", "tlkDivisions", "UpdElernents", "UpdElernentAnswers", "UpdElernentAnswerPhotoes", "UpdLocationMetaDatas", "Users" ]

} }

Wednesday, February 13, 13

Page 24: Tulsa Dev Lunch iOS at Work

http://yourhost.com/service.svc/ElementTypes?Sfor.mat=json

{ "d" : [ { " metadata": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1)", "type": "YourDataModel.ElementType" }, "elementTypeid": 1, "name": "POS 1&2 Camera", "elementGroupid": 1, "lastModified": "\/Date(1340728631167)\/", "active": true, "elementiconid" : 3 4, "Elements" : { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / Elements" } } , "ElementGroup": { "_deferred": { "uri": "http://yourhost.com/service.svc/ElementTypes(1)/ElementGroup" } } , "Elementicon" : { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / Elementicon" } }, "ElementRequirements": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / ElementReguirements" } }, "DrawingTypes": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) /DrawingTypes " } }, "ElementQuestions": { "_deferred": { "uri": "http: //yourhost.com/service.svc / ElementTypes(1) / ElementOuestions" } }

} ' { " metadata": { "uri": "http: //yourhost.com/service.svc / ElementTypes(2)", "type": "YourDataModel.ElementType" }, "elementTypeid": 2, "name": "POS 3&4 Camera", "elementGroupid": 1, "lastModified": "\/Date(1340728631167)\/",

Wednesday, February 13, 13

Page 25: Tulsa Dev Lunch iOS at Work

http://yourhost.com/service.svc/ElementTypes(l)?Sformat=json& Sexpand=ElementGroup { "d" : { " metadata" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)", "type" : "QTSecurityModel . ElementType" }, "elementTypei d": 1, "name" : "POS 1&2 Camera", "elementGroupid" : 1 , "lastModif i ed" : "\/Date( 1340728631167) \/", "active": true, "elementiconi d" : 34, "Elements" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/Elements" } } , "ElementGroup": { "_metadata": { "uri": "http://yourhost.com/service.svc/ElementGroups(1)", "type": "QTSecurityModel.ElementGroup" }, "elementGroupid": 1, "name": "Cameras", "sortOrder": 1, "lastModified": "\/Date(1340289282327)\/", "active": true, "ElementTypes": { "_deferred": { "uri": "http://yourhost.com/service.svc/ElementGroups(1)/ElementTypes" } } } , "Element i con" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/Elementi con" } }, "ElementRequirements" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/ElementReguirements" } } , "DrawingTypes" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/DrawingTypes" } }, "ElementQuestions" : { "_ def erred" : { "uri": "http : //yourhost.com/service.svc/ElementTypes(1)/ElementOuestions" } } } }

Wednesday, February 13, 13

Page 26: Tulsa Dev Lunch iOS at Work

The Mobile Dev

POV

Wednesday, February 13, 13

Page 27: Tulsa Dev Lunch iOS at Work

+(id) syncRequest: (NSString *) urlString error:(NSError **) error { NSLog(@"syncRequest: %@",urlString); urlString=[SyncHelper addJsonToUri:urlString]; // Adds ?$format=json to URL NSURL *url = [NSURL URLWithString:urlString]; NSError *internalError = nil; NSURLResponse *response=nil; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; }

NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (!internalError) { internalError=nil; NSDictionary *interimDict = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers|NSJSONReadingAllowFragments error:&internalError]; if (internalError!=nil) { NSLog(@"Error parsing JSON from syncRequest: %@ ",[internalError debugDescription]); if (error!=nil) *error=internalError; return nil; } NSDictionary *errorDict = [interimDict objectForKey:@"error"]; if (errorDict!=nil) { NSDictionary *messageDict = [errorDict objectForKey:@"message"]; NSString *errorMessage = [messageDict objectForKey:@"value"]; if (error!=nil) *error=[NSError errorWithDomain:ERROR_DOMAIN code:4000 localizedDescription:[NSString stringWithFormat:@"Error received from server: %@",errorMessage]]; return nil; }

Reading Data

Wednesday, February 13, 13

Page 28: Tulsa Dev Lunch iOS at Work

id retVal = [interimDict objectForKey:@"d"]; if ([retVal isKindOfClass:[NSDictionary class]] && [((NSDictionary *)retVal) objectForKey:@"results"]!=nil) { return [((NSDictionary *)retVal) objectForKey:@"results"]; } else { return retVal; } } else { NSLog(@"Error: unable to complete web request because - %@",[internalError localizedDescription]); if (error!=nil) *error=internalError; return nil; }}

If result is a list, an NSArray

of NSMutableDictionary’s is

returned.

Otherwise, an NSMutableDictionary is returned.

Wednesday, February 13, 13

Page 29: Tulsa Dev Lunch iOS at Work

+(BOOL) insertEntity:(id) entity entityName:(NSString *)entityName error:(NSError **) error{ NSString *urlString = [SyncHelper urlStringForEntity:entityName]; // Turns “EntityName” into “http://yourserver/service.svc/EntityName NSURL *url = [NSURL URLWithString:urlString]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url];

[request setHTTPMethod:@"POST"];[request addValue:@"Application/json" forHTTPHeaderField:@"content-type"];[request addValue:@"Application/json" forHTTPHeaderField:@"accept"];[request setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];

if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; } NSError *internalError = nil; NSData *payload = [NSJSONSerialization dataWithJSONObject:entity options:NSJSONWritingPrettyPrinted error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } [request setHTTPBody: payload]; NSHTTPURLResponse *response = nil;

internalError = nil; NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } NSString *responseStatus = [NSHTTPURLResponse localizedStringForStatusCode:[response statusCode]]; if ([response statusCode]!=201) { if (error!=nil) *error = [NSError errorWithDomain:ERROR_DOMAIN code:100

localizedDescription:[NSString stringWithFormat:@"HTTP ERROR (%i) %@",[response statusCode],responseStatus]]; } return [response statusCode]==201; }

Inserting New Data

Wednesday, February 13, 13

Page 30: Tulsa Dev Lunch iOS at Work

+(BOOL) updateEntity:(NSMutableDictionary *)entity forKeys:(NSArray *)keys error:(NSError **) error { NSDictionary *metadata = [entity valueForKey:@"__metadata"]; if (metadata==nil) return NO; NSURL *url = [NSURL URLWithString:[metadata valueForKey:@"uri"]]; NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; [request setHTTPMethod:@"POST"];

[request addValue:@"MERGE" forHTTPHeaderField:@"X-HTTP-Method"]; [request addValue:@"Application/json" forHTTPHeaderField:@"content-type"];[request addValue:@"Application/json" forHTTPHeaderField:@"accept"];[request setValue:@"gzip" forHTTPHeaderField:@"Accept-Encoding"];

if (HTTP_USER!=nil && [HTTP_USER length]>0 && HTTP_PASSWORD!=nil && [HTTP_PASSWORD length]>0) { NSString *authStr = [NSString stringWithFormat:@"%@:%@",HTTP_USER,HTTP_PASSWORD]; NSData *authData = [authStr dataUsingEncoding:NSASCIIStringEncoding]; NSString *authValue = [NSString stringWithFormat:@"Basic %@", [authData base64EncodingWithLineLength:80]]; [request setValue:authValue forHTTPHeaderField:@"Authorization"]; } NSMutableDictionary *payloadDict = [NSMutableDictionary dictionaryWithCapacity:10]; [payloadDict setValue:metadata forKey:@"__metadata"]; for (NSString *key in keys) { [payloadDict setValue:[entity valueForKey:key] forKey:key]; } NSError *internalError = nil; NSData *payload = [NSJSONSerialization dataWithJSONObject:payloadDict options:NSJSONWritingPrettyPrinted error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } [request setHTTPBody: payload]; NSHTTPURLResponse *response = nil;

internalError = nil; NSData *data = [NSURLConnection sendSynchronousRequest:request returningResponse:&response error:&internalError]; if (internalError != nil) { if (error!=nil) *error = internalError; return NO; } NSString *responseStatus = [NSHTTPURLResponse localizedStringForStatusCode:[response statusCode]]; if ([response statusCode]!=204) { if (error!=nil) *error = [NSError errorWithDomain:ERROR_DOMAIN code:5000

localizedDescription:[NSString stringWithFormat:@"HTTP ERROR (%i) %@",[response statusCode],responseStatus]]; } return [response statusCode]==204;}

Updating Data

Wednesday, February 13, 13

Page 31: Tulsa Dev Lunch iOS at Work

What to Do With an NSMutableDictionary?

1.) Parse into proper objects

2.) Create a wrapper object that stores NSMutableDictionary internally

3.) Use Objective-C Categories to add field-like methods to NSMutableDictionary

But don’t just [object objectForKey: @“propertyName“]

Wednesday, February 13, 13

Page 32: Tulsa Dev Lunch iOS at Work

//// NSMutableDictionary+Customer.m// Yale Cleaners//// Created by Matt Galloway on 8/31/12.// Copyright (c) 2012 Architactile LLC. All rights reserved.//

#import "NSMutableDictionary+Customer.h"

@implementation NSMutableDictionary (Customer)

#pragma mark - Customer Custom Getters

-(NSString *) mobileNumber { return [self filteredObjectForKey:@"Mobile_no"];}

-(NSString *) sendEmail { return [self filteredObjectForKey:@"SendEmail"];}

-(NSString *) sendReceipt { return [self filteredObjectForKey:@"SendReceipt"];}

-(NSString *) sendText { return [self filteredObjectForKey:@"SendText"];}

-(NSString *) username { return [self filteredObjectForKey:@"User_Name"];}

-(NSString *) uri { return [self filteredObjectForKey:@"uri"];}

-(NSString *) address { return [self filteredObjectForKey:@"address"];

-(NSString *) area { return [self filteredObjectForKey:@"area"];}

-(NSString *) charge { return [self filteredObjectForKey:@"charge"];}

-(NSString *) city { return [self filteredObjectForKey:@"city"];}...

Cate

gory

Exa

mple

Wednesday, February 13, 13

Page 33: Tulsa Dev Lunch iOS at Work

Use HTTPS +

Authentication

(at a minimum)

Wednesday, February 13, 13

Page 34: Tulsa Dev Lunch iOS at Work

Local Data Store?

Meet SQLite &

CoreData

Wednesday, February 13, 13

Page 35: Tulsa Dev Lunch iOS at Work

CoreData is one of iOS’s

greatest advantages over

Android for business apps.

Wednesday, February 13, 13

Page 36: Tulsa Dev Lunch iOS at Work

PrtmaryK_e_;y __ --1

OJeCt Pr • AttllbU folderNam j sonl.ast'li name: proJectld syn<Corn

IC!S e

~ocM1ed

!)lete nships Re ~l i O

draw-ng­elementRe

ypes qul rements fc::

1

• Anr butt~ hc: dlypc: JSOnW~t~Od i fied loatoorMet~DJ.tJfield ld ,~e

pockl stCho ces required • Rclattonships location~c:tt~Oata <

--:

Louuonvet.lO.lt.l-, Annb~o~tcs

jsonlilst Y.od I fled ocatton~ctOJDiltald vo~lutBool

v.llueno~t

VJ.Iuelnt value Text

Rc ta11onsh1 ps

!ocatton

-

-.;

C Locat_oo_n __ ----,~ 19 Auro butes OJddrusl .lddreu2 City folde r'\ arne htghCr.mc:Locat.on J S.onuu~od oticd

loutoOf'lld loutlonNu m ber l'ame state ~urveyCiodT me surveyor sut\leySct'leduled-IIM

:~:~::~:d~~ cotN\ple-Le syt'I(Complete I J ' ~ 2op

Re :ltoonshlps >dto'lwings Elemc:ntRc:qu rc:rrc:f'll

e emet~tRequiremc:nu Anrobutes D•aw r.g ::__j 0r.Jwingiy_JX ___ 1 • Attribute~ ·--..-j~ ~----+ locattonl\'etaOata e ementRequirementld

Attributes 1---------drolw n!lld project JSOt'll...lS tMOCifled dr.w,,ngTypeld fa en~rne m.lXAllcw.ed JSOnl...lstMOOif1ed m nRc:qu•rcd name heoght Relauonshops

Re atton~h ps JSOf'll.astMod•f1ed ,.----+-----------1-- ,..> drav. n!jType

c:oc:mc:ntRcquirc:mcnts scale 1,..:::::1

OementTyoe E ementCroup 9 Annblltes

dril'Wings A::llamc: i---1-~---+----------+--+~c: cmc:ntTypc

c c:mentType5 wlctth l := ===------:!-------------lL...--~~~ocatoon ----'

ects J Re ·" •onship~ d ~ !---t-~PI'OJ ____ __,. d t.lWlngTypt "

• Ann b"tc:s c:lememTypeld elementGroupld ,conl tlename JsonLut'-1oct'lc:d Jsonl..ut'Aocif,c:d n;amc: narrc: sortOrder Rc: o'!t on\hops

Rel.'lt on·""s~h"'t pc;s===i drilv.ongTypu /\'.,.» elementCroup

c:lementQ .. estlons c:lementRequ lremen ts c:lc:mc:nts

~ Eltmc:ntQue stlon

Attrobutes

eemenu _ 1,_ ~ t.~ ~ cc Element l ess

AUt ib'->ttS dra\~o, ng(()Q(dX 1\.J ~;::·~~;=~:n V\ er w ·,t. h elementld clcmentll.umber

jsonl..ut'-1oditied l c: c:mc:ntQI.esbonld nelpPnotofo ename

n.1me ~ 'f RelatoOnSh pS m 0 t. ~~ s !-------\c:iementAnswc:rs no help-tltt

nchesMax nches\4ln json~t\lod • tied pnoto.AI owNOte~

:>hotoM.1xCount ohotoM nCount photoRc:qu rel'l.otcs p cl(L siC no cc:s quest Of'ITtxt quest on- 'fpe required sortOrdc:r tc:xtMaxlength

Rc: iltooqhips

E c:mentAnswc:r Auro butes

OJnswc: rlnt .'lnswerText created On e emen!An~"erld gpslatttudc: gpslong tude jsonLmVoct ofoed IJ.stl'-'\od ned

Re iltoons"'lps e ement

0 emc ntA.11s"'..: rPhoto Aur iba.tes

crc;atcdOn etementAnswerPhotold gpsUtotude

gpslottgltude J t'ludong jsonLOJst'Aodoficd .'lnMod fied ~otorden~e

photoNotts

Code ~nd no

SQL •

: :::~~~;~;:rs "<;_-------------------,fmentAn~"-C!rPhotos +u

Wednesday, February 13, 13

Page 37: Tulsa Dev Lunch iOS at Work

To Recap...

Android sucks.

Mobilize your web assets.

Consider the mobile web first.

Use RESTful APIs.

Avoid SQL & SOAP.

CoreData is way worth it.Wednesday, February 13, 13

Page 38: Tulsa Dev Lunch iOS at Work

Matt Galloway

(Freelance Mobile Developer Extraordinaire)

[email protected]

918-808-3072

Wednesday, February 13, 13