Working with NSURLSession: Part 3

This post is part of a series called Working with NSURLSession.
Networking with NSURLSession: Part 2
Working with NSURLSession: Part 4
In the previous tutorials, we explored the fundamentals of the NSURLSession API. There is one other feature of the NSURLSession API that we haven't look into yet, that is, out-of-process uploads and downloads. In the next two tutorials, I will show you how to create a very simple podcast client that enables background downloads.

The podcast client that we're about to create isn't really going to be that functional. It will allow the user to query the iTunes Search API for a list of podcasts, select a podcast, and download episodes. Since we are focusing on the NSURLSession API, we won't go into playing the episodes the application downloads.
The project, however, will teach you how to use data tasks and download tasks in a real world application. The podcast client will also enable background downloads for which we'll leverage NSURLSession's out-of-process API. We have quite a few things to do so let's not waste time and get started.
Fire up Xcode 5, select New > Project... from the File menu, and choose the Single View Application template from the list of iOS application templates. Name the application Singlecast, set the Device Family to iPhone, and tell Xcode where you'd like to save the project. Hit Create to create the project.
Create the project in Xcode.
Configure the project in Xcode.
The first thing we need to do is edit the project's main storyboard. Open Main.storyboard, select the storyboard's only view controller, and choose Embed In > Navigation Controller from the Editor menu. The reason for embedding the view controller in a navigation controller will become clear later in this tutorial.
Embed the view controller in a navigation controller.
As I mentioned in the introduction, to keep things simple, the user will only be able to subscribe to one podcast. Let's start by creating the search view controller. Select New > File... from the File menu and choose Objective-C class from the options on the right. Name the class MTSearchViewController and make it a subclass of UIViewController. Leave the check box labeled With XIB for user interface unchecked. Tell Xcode where you want to save the class files and hit Create.
Create the files for the MTSearchViewController class.
Before we create the user interface, open the view controller's header file and update the class's interface as shown below. We specify that the MTSearchViewController class conforms to the UITableViewDataSource, UITableViewDelegate, and UISearchBarDelegate protocols, we declare two outlets, searchBar and tableView as well as an action, cancel, to dismiss the search view controller.
01
02
03
04
05
06
07
08
09
10
#import <UIKit/UIKit.h>
 
@interface MTSearchViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
 
@property (weak, nonatomic) IBOutlet UISearchBar *searchBar;
@property (weak, nonatomic) IBOutlet UITableView *tableView;
 
- (IBAction)cancel:(id)sender;
 
@end
Revisit the project's main storyboard and drag a new view controller from the Object Library on the right. Select the new view controller, open the Identity Inspector on the right, and set the view controller's class to MTSearchViewController. With the new view controller still selected, open the Editor menu and choose Embed In > Navigation Controller. Drag a table view to the view controller's view and connect the table view's dataSource and delegate outlets with the search view controller.
Add a table view to the search view controller's view and connect its dataSource and delegate outlets.
With the table view still selected, open the Attributes Inspector, and set the number of prototype cells to 1. Select the prototype cell and set its style property to Subtitle and its identifier to SearchCell.
Create and configure a prototype cell.
Drag a search bar from the Object Library and add it to the table view's header view. Select the search bar and connect its delegate outlet with the view controller.
Add a search bar to the table view's header view and connect the delegate outlet.
Select the view controller and connect its searchBar and tableView outlets with the search bar and table view respectively. There are a few other things that we need to do before we're done with the storyboard.
Open the Object Library and drag a bar button item to the navigation bar. Select the bar button item, connect it with the cancel: action we declared in the search view controller's interface, and change its Identifier in the Attributes Inspector to Cancel.
Add a bar button item to the search view controller.
Drag a bar button item to the navigation bar of the view controller (not the search view controller) and change its Identifier in the Attributes Inspector to Add. Control drag from the bar button item to the search view controller's navigation controller and select modal from the menu that pops up. This creates a segue from the view controller to the search view controller's navigation controller.
If you were to control drag from the view controller's bar button item directly to the search view controller instead of its navigation controller, the navigation controller would never be instantiated and you wouldn't see a navigation bar at the top of the search view controller.
Create a segue from the view controller to the search view controller's navigation controller.
Before we implement the UITableViewDataSource and UITableViewDelegate protocols in the MTSearchViewController class, we need to declare a property that stores the search results we'll get back from the iTunes Search API. Name the property podcasts as shown below. We also declare a static string that will serve as a cell reuse identifier. It corresponds to the identifier we set on the prototype cell a few moments ago.
1
2
3
4
5
6
7
#import "MTSearchViewController.h"
 
@interface MTSearchViewController ()
 
@property (strong, nonatomic) NSMutableArray *podcasts;
 
@end
1
static NSString *SearchCell = @"SearchCell";
The implementation of numberOfSectionsInTableView: is as easy as it gets. We return 1 if self.podcasts is not nil and 0 if it is. The implementation of tableView:numberOfRowsInSection: is pretty similar as you can see below. In tableView:cellForRowAtIndexPath:, we ask the table view for a cell by passing the cell reuse identifier, which we declared earlier, and indexPath. We fetch the corresponding item from the podcasts data source and update the table view cell. Both tableView:canEditRowAtIndexPath: and tableView:canMoveRowAtIndexPath: return NO.
1
2
3
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return self.podcasts ? 1 : 0;
}
1
2
3
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.podcasts ? self.podcasts.count : 0;
}
01
02
03
04
05
06
07
08
09
10
11
12
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:SearchCell forIndexPath:indexPath];
 
    // Fetch Podcast
    NSDictionary *podcast = [self.podcasts objectAtIndex:indexPath.row];
 
    // Configure Table View Cell
    [cell.textLabel setText:[podcast objectForKey:@"collectionName"]];
    [cell.detailTextLabel setText:[podcast objectForKey:@"artistName"]];
 
    return cell;
}
1
2
3
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath {
    return NO;
}
1
2
3
- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath {
    return NO;
}
Before running the application, implement the cancel: action in which we dismiss the search view controller.
1
2
3
- (IBAction)cancel:(id)sender {
    [self dismissViewControllerAnimated:YES completion:nil];
}
Build the project and run the application to make sure that the foundation is working as expected. It's time to start using the NSURLSession API to query the iTunes Search API.
Let's begin by declaring two additional private properties in the MTSearchViewController class, session and dataTask. The session variable is used to store a reference to the NSURLSession instance we'll be using for querying Apple's API. We also keep a reference to the data task that we will use for the request. This will enable us to cancel the data task if the user updates the search query before we've received a response from the API. If you have an eye for detail, you may have noticed that the MTSearchViewController class also conforms to the UIScrollViewDelegate protocol. The reason for this will become clear in a few minutes.
01
02
03
04
05
06
07
08
09
10
#import "MTSearchViewController.h"
 
@interface MTSearchViewController () <UIScrollViewDelegate>
 
@property (strong, nonatomic) NSURLSession *session;
@property (strong, nonatomic) NSURLSessionDataTask *dataTask;
 
@property (strong, nonatomic) NSMutableArray *podcasts;
 
@end
The session is created in its getter method as you can see below. Its implementation shouldn't hold any surprises if you've read the previous tutorials. We override the getter method of the session property to lazily load the session and confine the session's instantiation and configuration in its getter method. This makes for clean and elegant code.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
- (NSURLSession *)session {
    if (!_session) {
        // Initialize Session Configuration
        NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
 
        // Configure Session Configuration
        [sessionConfiguration setHTTPAdditionalHeaders:@{ @"Accept" : @"application/json" }];
 
        // Initialize Session
        _session = [NSURLSession sessionWithConfiguration:sessionConfiguration];
    }
 
    return _session;
}
To respond to the user's input in the search bar, we implement searchBar:textDidChange: of the UISearchBarDelegate protocol. The implementation is simple. If searchText is nil, the method returns early. If the length of searchText is less than four characters long, we reset the search by invoking resetSearch. If the query is four characters or longer, we perform a search by calling performSearch on the search view controller.
01
02
03
04
05
06
07
08
09
10
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
    if (!searchText) return;
 
    if (searchText.length <= 3) {
        [self resetSearch];
 
    } else {
        [self performSearch];
    }
}
Before we inspect performSearch, let's take a quick look at resetSearch. All that we do in resetSearch is clearing the contents of podcasts and reloading the table view.
1
2
3
4
5
6
7
- (void)resetSearch {
    // Update Data Source
    [self.podcasts removeAllObjects];
 
    // Update Table View
    [self.tableView reloadData];
}
The heavy lifting is done in performSearch. After storing the user's input in a variable named query, we check if dataTask is set. If it is set, we call cancel on it. This is important as we don't want to receive a response from an old request that may no longer be relevant to the user. This is also the reason why we have only one active data task at any one time. There is no advantage in sending multiple requests to the API.
Next, we ask the session for a new data task instance by passing it an NSURL instance and a completion handler. Remember that the session is the factory that creates tasks. You should never create tasks yourself. If we get a valid data task from the session, we call resume on it as we saw in the previous tutorials.
The logic inside the completion handler is interesting to say the least. The error object is important to us for several reasons. Not only will it tell us if something went wrong with the request, but it's also useful for determining if the data task was canceled. If we do get an error object, we check whether its error code is equal to -999. This error code indicates the data task was canceled. If we get another error code, we log the error to the console. In a real application, you'd need to improve the error handling and notify the user when an error is thrown.
If no error was passed to the completion handler, we create a dictionary from the NSData instance that was passed to the completion handler and we extract the results from it. If we have an array of results to work with, we pass it to processResults:. Did you notice we invoked processResults: in a GCD (Grand Central Dispatch) block? Why did we do that? I hope you remember, because it's a very important detail. We have no guarantee that the completion handler is invoked on the main thread. Since we need to update the table view on the main thread, we need to make sure that processResults: is called on the main thread.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
- (void)performSearch {
    NSString *query = self.searchBar.text;
 
    if (self.dataTask) {
        [self.dataTask cancel];
    }
 
    self.dataTask = [self.session dataTaskWithURL:[self urlForQuery:query] completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
        if (error) {
            if (error.code != -999) {
                NSLog(@"%@", error);
            }
 
        } else {
            NSDictionary *result = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
            NSArray *results = [result objectForKey:@"results"];
 
            dispatch_async(dispatch_get_main_queue(), ^{
                if (results) {
                    [self processResults:results];
                }
            });
        }
    }];
 
    if (self.dataTask) {
        [self.dataTask resume];
    }
}
Before we look at the implementation of processResults:, I want to quickly show you what happens in urlForQuery:, the helper method we use in performSearch. In urlForQuery:, we replace any spaces with a + sign to ensure that the iTunes Search API is happy with what we send it. We then create an NSURL instance with it and return it.
1
2
3
4
- (NSURL *)urlForQuery:(NSString *)query {
    query = [query stringByReplacingOccurrencesOfString:@" " withString:@"+"];
    return [NSURL URLWithString:[NSString stringWithFormat:@"https://itunes.apple.com/search?media=podcast&entity=podcast&term=%@", query]];
}
In processResults:, the podcasts variable is cleared, populated with the contents of results, and the results are displayed in the table view.
01
02
03
04
05
06
07
08
09
10
11
12
- (void)processResults:(NSArray *)results {
    if (!self.podcasts) {
        self.podcasts = [NSMutableArray array];
    }
 
    // Update Data Source
    [self.podcasts removeAllObjects];
    [self.podcasts addObjectsFromArray:results];
 
    // Update Table View
    [self.tableView reloadData];
}
When the user taps a row in the table view to select a podcast, tableView:didSelectRowAtIndexPath: of the UITableViewDelegate protocol is invoked. Its implementation may seem odd at first so let me explain what's going on. We select the podcast that corresponds with the user's selection, store it in the application's user defaults database, and dismiss the search view controller. We don't notify anyone about this? Why we do this will become clear once we continue implementing the MTViewController class.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
 
    // Fetch Podcast
    NSDictionary *podcast = [self.podcasts objectAtIndex:indexPath.row];
 
    // Update User Defatuls
    NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
    [ud setObject:podcast forKey:@"MTPodcast"];
    [ud synchronize];
 
    // Dismiss View Controller
    [self dismissViewControllerAnimated:YES completion:nil];
}
There are two details I want to talk about before returning to the MTViewController class. When the search view controller is presented to the user, it is clear that she wants to search for podcasts. It is therefore a good idea to immediately present the keyboard. We do this in viewDidAppear: as shown below.
1
2
3
4
5
6
- (void)viewDidAppear:(BOOL)animated {
    [super viewDidAppear:animated];
 
    // Show Keyboard
    [self.searchBar becomeFirstResponder];
}
The keyboard needs to hide the moment the user starts scrolling through the search results. To accomplish this, we implement scrollViewDidScroll: of the UIScrollViewDelegate protocol. This explains why MTSearchViewController conforms to the UIScrollViewDelegate protocol. Have a look at the implementation of scrollViewDidScroll: shown below.
1
2
3
4
5
- (void)scrollViewDidScroll:(UIScrollView *)scrollView {
    if ([self.searchBar isFirstResponder]) {
        [self.searchBar resignFirstResponder];
    }
}
The UITableView class is a subclass of UIScrollView, which is the reason the above approach works.
As we saw earlier, we store the user's selection in the application's user defaults database. We need to update the MTViewController class to make use of the user's selection in the search view controller. In the view controller's viewDidLoad method, we load the podcast from the user defaults database and we add the view controller as an observer of the user defaults database for the key path MTPodcast so that the view controller is notified when the value for MTPodcast changes.
1
2
3
4
5
6
7
8
9
- (void)viewDidLoad {
    [super viewDidLoad];
 
    // Load Podcast
    [self loadPodcast];
 
    // Add Observer
    [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"MTPodcast" options:NSKeyValueObservingOptionNew context:NULL];
}
All we do in loadPodcast is storing the value for MTPodcast from the user defaults database in the view controller's podcast property. This value will be nil if the user defaults database doesn't contain an entry for MTPodcast. The view controller will gracefully handle this for us. Remember that, in Objective-C, you can send messages to nil without all hell breaking loose. This has its disadvantages, but it certainly has its advantages to.
1
2
3
4
- (void)loadPodcast {
    NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
    self.podcast = [ud objectForKey:@"MTPodcast"];
}
This also means that we need to declare a property named podcast in the view controller's implementation file.
1
2
3
4
5
6
7
#import "MTViewController.h"
 
@interface MTViewController ()
 
@property (strong, nonatomic) NSDictionary *podcast;
 
@end
Let's also take a quick look at setPodcast: and updateView.
1
2
3
4
5
6
7
8
- (void)setPodcast:(NSDictionary *)podcast {
    if (_podcast != podcast) {
        _podcast = podcast;
 
        // Update View
        [self updateView];
    }
}
1
2
3
4
- (void)updateView {
    // Update View
    self.title = [self.podcast objectForKey:@"collectionName"];
}
When the value in the user defaults database changes for the key MTPodcast, the view controller can respond to this change in observeValueForKeyPath:ofObject:change:context:. That's how key value observing works. All we do in this method is updating the value of the view controller's podcast property.
1
2
3
4
5
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
    if ([keyPath isEqualToString:@"MTPodcast"]) {
        self.podcast = [object objectForKey:@"MTPodcast"];
    }
}
When working with key value observing, it is instrumental to be aware of memory management and retain cycles. In this case, it means that we need to remove the view controller as an observer when the view controller is deallocated.
1
2
3
- (void)dealloc {
    [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"MTPodcast"];
}
The response we get back from the iTunes Search API includes a feedUrl attribute for each podcast. We could manually fetch the feed and parse it. However, to save some time, we'll make use of MWFeedParser, a popular library that can do this for us. You can manually download and include the library in your project, but I am going to opt for Cocoapods. I prefer Cocoapods for managing dependencies in iOS and OS X projects. You can read more about Cocoapods on its website or on Mobiletuts+.
I am going to assume the Cocoapods gem is installed on your system. You can find detailed instructions in this tutorial.
Quit Xcode, navigate to the root of your Xcode project, and create a file named Podfile. Open this file in your text editor of choice and add the following three lines of code. In the first line, we specify the platform and the deployment target, which is iOS 7 in this example. The next two lines each specify a dependency of our Xcode project. The first one is the MWFeedParser library and I've also included the popular SVProgressHUD library, which will come in handy a bit later.
1
2
3
4
platform :ios, '7'
 
pod 'MWFeedParser'
pod 'SVProgressHUD'
Open a Terminal window, navigate to the root of your Xcode project, and execute the command pod install. This should install the dependencies and create an Xcode workspace. When Cocoapods is finished installing the project's dependencies, it tells you to use the workspace it created for you. This is important so don't ignore this advice. In the root of your Xcode project, you will see that Cocoapods has indeed created an Xcode workspace for you. Double-click this file and you should be ready to go.
Install the project's dependencies using Cocoapods.
Open the implementation file of the MTViewController class, add an import statement for MWFeedParser and SVProgressHUD, and declare two properties, episodes and feedParser. We also need to make MTViewController conform to the MWFeedParserDelegate protocol.
01
02
03
04
05
06
07
08
09
10
11
12
#import "MTViewController.h"
 
#import "MWFeedParser.h"
#import "SVProgressHUD.h"
 
@interface MTViewController () <MWFeedParserDelegate>
 
@property (strong, nonatomic) NSDictionary *podcast;
@property (strong, nonatomic) NSMutableArray *episodes;
@property (strong, nonatomic) MWFeedParser *feedParser;
 
@end
Next, we update setPodcast: by invoking fetchAndParseFeed, a helper method in which we use the MWFeedParser class to fetch and parse the podcast's feed.
01
02
03
04
05
06
07
08
09
10
11
- (void)setPodcast:(NSDictionary *)podcast {
    if (_podcast != podcast) {
        _podcast = podcast;
 
        // Update View
        [self updateView];
 
        // Fetch and Parse Feed
        [self fetchAndParseFeed];
    }
}
In fetchAndParseFeed, we get rid of our current MWFeedParser instance if we have one and initialize a new instance with the podcast's feed URL. We set the feedParseType property to ParseTypeFull and set the view controller as the feed parser's delegate. Before we fetch the feed, we use SVProgressHUD to show a progress HUD to the user.
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- (void)fetchAndParseFeed {
    if (!self.podcast) return;
 
    NSURL *url = [NSURL URLWithString:[self.podcast objectForKey:@"feedUrl"]];
    if (!url) return;
 
    if (self.feedParser) {
        [self.feedParser stopParsing];
        [self.feedParser setDelegate:nil];
        [self setFeedParser:nil];
    }
 
    // Clear Episodes
    if (self.episodes) {
        [self setEpisodes:nil];
    }
 
    // Initialize Feed Parser
    self.feedParser = [[MWFeedParser alloc] initWithFeedURL:url];
 
    // Configure Feed Parser
    [self.feedParser setFeedParseType:ParseTypeFull];
    [self.feedParser setDelegate:self];
 
    // Show Progress HUD
    [SVProgressHUD showWithMaskType:SVProgressHUDMaskTypeGradient];
 
    // Start Parsing
    [self.feedParser parse];
}
We also need to implement two methods of the MWFeedParserDelegate protocol, feedParser:didParseFeedItem: and feedParserDidFinish:. In feedParser:didParseFeedItem:, we initialize the episodes property if necessary and pass it the feed item that the feed parser hands to us.
1
2
3
4
5
6
7
- (void)feedParser:(MWFeedParser *)parser didParseFeedItem:(MWFeedItem *)item {
    if (!self.episodes) {
        self.episodes = [NSMutableArray array];
    }
 
    [self.episodes addObject:item];
}
In feedParserDidFinish:, we dismiss the progress HUD and update the table view. Did you say table view? That's right. We need to add a table view and implement the necessary UITableViewDataSource protocol methods.
1
2
3
4
5
6
7
- (void)feedParserDidFinish:(MWFeedParser *)parser {
    // Dismiss Progress HUD
    [SVProgressHUD dismiss];
 
    // Update View
    [self.tableView reloadData];
}
Advertisement
Before we update the user interface, open MTViewController.h, declare an outlet for the table view, and tell the compiler the MTViewController class conforms to the UITableViewDataSource and UITableViewDelegate protocols.
1
2
3
4
5
6
7
#import <UIKit/UIKit.h>
 
@interface MTViewController : UIViewController <UITableViewDataSource, UITableViewDelegate>
 
@property (weak, nonatomic) IBOutlet UITableView *tableView;
 
@end
Open the main storyboard one more time and add a table view to the view controller's view. Connect the table view's dataSource and delegate outlets with the view controller and connect the view controller's tableView outlet with the table view. Select the table view, open the Attributes Inspector, and set the number of prototype cells to 1. Select the prototype cell, set its style to Subtitle, and give it an identifier of EpisodeCell.
Adding a table view to the view controller.
Before we implement the UITableViewDataSource protocol, declare a static string named EpisodeCell in MTViewController.m. This corresponds with the identifier we set for the prototype cell in the storyboard.
1
static NSString *EpisodeCell = @"EpisodeCell";
Implementing the UITableViewDataSource protocol is simple as pie and very similar to how we implemented the protocol in the search view controller. The only difference is that the episodes variable contains instances of the MWFeedItem class instead of NSDictionary instances.
1
2
3
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
    return self.episodes ? 1 : 0;
}
1
2
3
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return self.episodes ? self.episodes.count : 0;
}
01
02
03
04
05
06
07
08
09
10
11
12
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:EpisodeCell forIndexPath:indexPath];
 
    // Fetch Feed Item
    MWFeedItem *feedItem = [self.episodes objectAtIndex:indexPath.row];
 
    // Configure Table View Cell
    [cell.textLabel setText:feedItem.title];
    [cell.detailTextLabel setText:[NSString stringWithFormat:@"%@", feedItem.date]];
 
    return cell;
}
1
2
3
- (BOOL)tableView:(UITableView *)tableView canEditRowAtIndexPath:(NSIndexPath *)indexPath {
    return NO;
}
1
2
3
- (BOOL)tableView:(UITableView *)tableView canMoveRowAtIndexPath:(NSIndexPath *)indexPath {
    return NO;
}
Run the application in the iOS Simulator or on a physical device and run it through its paces. You should now be able to search for podcasts, select a podcast from the list, and see its episodes.
We've done a lot in this tutorial, but we still have quite a bit of work in front of us. In the next tutorial, we zoom in on downloading episodes from the feed and we'll discuss background or out-of-process downloads. Stay tuned.

Comments

Popular posts from this blog

How to Create a Yoga Goddess Illustration in Adobe Illustrator

How to Create an Icon Set using Adobe Photoshop

Android Essentials: Using the Contact Picker