Chapter 3. Working the URL Loading System

In This Chapter

  • Understanding the URL loading system

  • Downloading data asynchronously

  • Using delegation in displaying data

  • Displaying the sights (as annotations) on a map

In the grand scheme of things, downloading only a small bit of data from the Internet has no impact on your app's performance, but when that begins to increase, you're going to run into trouble. That's because right now when the downloading is taking place, everything else stops. You can imagine, then, how annoyed the users will be when you start downloading lots of images and text for the sights in the RoadTrip application. Users may even have to get themselves another iPhone to keep themselves amused while waiting for the data to finish downloading.

Again, this isn't an issue in small simple apps, but it's something you must address if you're creating industrial-strength applications. By this I mean not just business applications, but anything that delivers a significant amount of content to the user.

Interestingly enough, the solution to the problem, the URL loading system, involves the use of delegation as well, as do many of the other synchronous processes you have and will use (the XML parser you used in Book V, for example).

Finally, when you do get the sights loaded, you're going to want to display them on the map. This chapter goes into more detail about how to do that. I also extend the map discussion from the last book and show you how to create model objects that can display themselves as annotations as well.

URL Loading System Overview

The URL loading system is a set of classes and protocols that provide the underlying capability for an application to access the data pointed to by a URL.

The most commonly used classes in the URL loading system allow an application to create a request for the contents of that URL and then download it from there.

Note

A request for the contents of a URL is represented by an NSURLRequest object — something you're already using. The NSURLRequest class encapsulates a URL and any protocol properties, in a way that is independent of the protocol itself.

An NSURLRequest contains two basic data elements of a load request: the URL to load and instructions on what to do with any cached data. It also enables you to set the timeout for a connection. In this discussion, I talk only about the data elements, but just remember that the timeout stuff is available as well, and it's something you may want to explore on your own.

Because you're already using an NSURLRequest in the current WebViewController as well as its Web View implementation (no coincidence here), all I have to do is show you how to add some additional functionality provided by the URL loading system to make the downloads asynchronous — in other words, going on while the user is using the application.

To do that, I show you how to use the NSURLConnection class to make the connection to the server needed by an NSURLRequest object and download the contents.

Authentication and credentials

When I first started programming on the iPhone, I was concerned about servers or Web sites that required a password to access them — how the heck do you do that? Although I don't use it in the example, this functionality is available to you in the URL loading system as well.

The URL loading system provides classes that you can use for credentials as well as providing secure credential persistence (or storage of the credentials). Credentials can be specified to persist for a single request, for the duration of an application's launch, or permanently in the user's keychain. As I said, although you don't need to use it here, I show you an example a bit later of how to deal with servers that require a username and password.

Cookie storage

As you may be aware, cookies are often used to provide storage of data across URL requests. The URL loading system provides interfaces to create and manage cookies as well as sending and receiving cookies from Web servers.

Protocol support

If you're unfamiliar with what it means, a protocol is a convention that defines a set of rules that computers follow to communicate with each other across a network. It includes rules for connection, communication, and data transfer between two computers. The URL loading system natively supports http, https, file, and ftp protocols, and also allows you to extend the protocols that are supported for transferring data.

Using NSURLConnection — Downloading data asynchronously

NSURLConnection provides the most flexible method of downloading the contents of a URL, so of course you'll be using it. (Only the best for my readers.) It provides a simple interface for creating and canceling a connection, and it does that by using delegate methods to control what happens during the process.

Although this may appear daunting at first, it actually isn't much more complex than the table views you've been using. In fact, all you need to do is create the connection and then implement a few delegate methods, such as

  • connection:didReceiveResponse:

  • connection:didReceiveData:

  • connection:didFailWithError:

  • connectionDidFinishLoading:

To implement asynchronous loading in the RoadTrip application, you need to create a new class: ServerManager.

By now you know the drill.

  1. Choose File

    Downloading data synchronously
  2. In the leftmost column of the dialog, first select Cocoa Touch Classes under the iPhone OS heading. Then select the Objective-C class template in the topmost pane, make sure the Subclass drop-down menu has NSObject selected, and then click Next.

    You see a new dialog asking for some more information.

  3. Enter ServerManager in the File Name field and then click Finish.

    You'll be using ServerManager as the main model class.

    Start by adding the code in bold in Listing 3-1 to ServerManager.h.

Example 3-1. Working with ServerManager.h

@class Trip;

@interface ServerManager : NSObject
                                   <UIAlertViewDelegate> {

  NSURLConnection *currentConnection;
  id               delegate;
  NSMutableData   *rxData;
  Trip            *trip;
  SEL              sucessful;
  SEL              connectionError;
}
- (id) initWithModel:(Trip*)theModel delegate:receiver
     sucessfulSelector:(SEL)sucessfulSelector
     connectionErrorSelector:(SEL)connectionErrorSelector;
- (BOOL) get:(NSString*)urlString;
- (void) cancelConnection;
- (void) cancel;
@end

I'll be explaining all the instance variables and methods as I go along, but I want to start with the get: method that creates the NSURLConnection I just spoke of.

Enter the code in Listing 3-2 to ServerManager.m.

Example 3-2. get: Makes the NSURLConnection

- (BOOL)get:(NSString *)urlString{

  [urlString retain];

  NSURLRequest *theRequest=[NSURLRequest
      requestWithURL:[NSURL URLWithString:urlString]
      cachePolicy:
      NSURLRequestReloadIgnoringLocalAndRemoteCacheData
      timeoutInterval:60.0];
  currentConnection=[[NSURLConnection alloc]
   initWithRequest:theRequest delegate:self];
  if (currentConnection) {
    rxData = [[NSMutableData alloc] init];
    return YES;
  }
  else {
    NSLog(@"request error ");
    [self cancel];
    return NO;
  }
}

The get: method in Listing 3-2 shows you how to initiate a connection for a URL, the first step in the downloading process. This connection links you to the server or Web site you want to download data from. It begins by creating an NSURLRequest instance for the URL, specifying the cache access policy and timeout interval for the connection.

You'll use NSURLRequestReloadIgnoringLocalAndRemoteCacheData, which specifies that not only should the local cache data be ignored, but that everyone else who might have been doing any data caching (proxies and other intermediates) should ignore their caches so far as the protocol allows. You do have a few other choices:

NSURLRequestUseProtocolCachePolicy
NSURLRequestReloadIgnoringLocalCacheData
NSURLRequestReturnCacheDataElseLoad
NSURLRequestReturnCacheDataDontLoad
NSURLRequestReloadRevalidatingCacheData

I leave you to explore these on your own.

Stepping through the code in Listing 3-2, you'll note that your next task is to specify a timeout of 60 seconds. Then you create an NSURLConnection instance using the NSURLRequest and specifying the delegate.

currentConnection=[[NSURLConnection alloc]
   initWithRequest:theRequest delegate:self];

If the connection is successful, you create an instance of NSMutableData to store the data that will be provided to the delegate incrementally.

if (currentConnection) {
    rxData = [[NSMutableData alloc] init];
    return YES;
}

If NSURLConnection can't create a connection for the request, initWithRequest:delegate: returns nil, logs the errors, and cancels the connection — [self cancel].

else {
    NSLog(@"request error ");
    [self cancel];
    return NO;
  }

Tip

The download starts immediately upon receiving the initWithRequest:delegate: message.

As I said, if things don't work out, you send yourself the cancel message. To set that up, enter the code in Listing 3-3 into ServerManager.m.

Example 3-3. Adding the cancel Message

- (void)cancel {
  if (currentConnection) {
    [self cancelConnection];
  }
  if (rxData) {
    [rxData release];
    rxData = nil;
  }
}

cancel releases rxdata (the buffer you are using to hold the downloaded data) if it has been allocated and sends itself the cancelConnection message. Enter the code in Listing 3-4 to ServerManager.m to get that message set up for you.

Example 3-4. The cancelConnection Message

- (void)cancelConnection {

  [currentConnection cancel];
  [currentConnection release];
  currentConnection = nil;
}

All this method does is cancel and release the connection and set the instance variable to nil;.

Note

You can cancel a connection any time before the delegate receives a connectionDidFinishLoading: or connection:didFailWithError: message by sending the connection a cancel message.

connection:didReceiveResponse:

After the connection has been established, the delegate receives connection:didReceiveResponse: message. This message lets you know the server is out there and ready to roll.

Note

You need to know that your delegate may receive the connection:didReceiveResponse: message more than once for a connection. This could happen as a result of a server redirect or for a couple of other obscure reasons. This means that each time a delegate receives the connection:didReceiveResponse: message, it will need to reset any progress indication and discard all previously received data. To have the delegate do that, add the code in Listing 3-5 to ServerManager.m.

Example 3-5. connection:didReceiveResponse:

- (void)connection:(NSURLConnection *)connection didReceiveRe
   sponse:(NSURLResponse *)response {

  [rxData setLength:0];
}

The code simply resets the length of the received data to 0 each time it's called.

connection:didReceiveData:

When the connection is established (and you've been informed that the remote computer has made the connection via connection:didReceiveResponse:), your delegate is sent the connection:didReceiveData: messages as the data is received — this message is the Big Kahuna. (This whole process is similar to the XML parser process you used in Book V, Chapter 6.)

Note

Because you may not get all the data in one fell swoop, you need to append the data as you receive it to any already received data in the NSMutableData object, rxdata, you created back in Listing 3-1.

This appending business is shown in Listing 3-6, which you should add to ServerManager.m.

Example 3-6. connection:didReceiveData:

- (void)connection:(NSURLConnection *)connection
                           didReceiveData:(NSData *)data {

  [rxData appendData:data];
}

You can also use the connection:didReceiveData: method to provide an indication of the connection's progress to the user.

connection:didFailWithError:

If an error crops up during the download, your delegate receives a connection:didFailWithError: message. You get an NSError object passed in as a parameter that specifies the details of the error. It also provides the URL of the request that failed in the user info dictionary using the key NSErrorFailingURLStringKey. I'll leave you to explore the innards of the NSError message on your own.

After your delegate receives a message connection:didFailWithError:, it's all over, and your delegate won't get any more messages for that connection.

Enter the code in Listing 3-7 into ServerManager.m to get this bit to work for you.

Example 3-7. connection:didFailWithError:

- (void)connection:(NSURLConnection *)connection
                       didFailWithError:(NSError *)error {
  [self cancel];
  [delegate performSelectorOnMainThread:connectionError
   withObject:error waitUntilDone:YES];

}

This code cancels the connection, sending itself the cancel message (which you implemented in Listing 3-4). It also performs the selector you specify when you initialize ServerManager. (You do that at the end of this section — "first things last" sometimes makes the flow easier to understand.) Because this is specific to a given application, and not your general implementation of the URL Loading System, I show you the business about performing the selector when I explain how Sight will use ServerManager.

connectionDidFinishLoading:

Finally, when the connection succeeds in downloading the request, your delegate receives the connectionDidFinishLoading: message. — and it's all over. Your delegate will receive no further messages from the connection, and you can release the NSURLConnection object.

Enter the code in Listing 3-8 to ServerManager.m to put the icing on the cake.

Example 3-8. connectionDidFinishLoading:

- (void)connectionDidFinishLoading:(NSURLConnection *)
                                              connection {

//RoadTripAppDelegate *appDelegate =
//                  RoadTripAppDelegate *) [[UIApplication
//                           sharedApplication] delegate];
//UIApplication* application = [UIApplication
//                                     sharedApplication];
//[appDelegate.activityIndicator stopAnimating];
//application.networkActivityIndicatorVisible = NO;
  [delegate performSelectorOnMainThread:sucessful
   withObject:rxData waitUntilDone:YES];
  [self cancelConnection];
}

You simply perform the selector that you specify during ServerManager initialization. (Not sure what "performing a selector," as in performSelectorOnMainThread:sucessful, might actually mean? Don't worry; I explain that — and talk about the selector you should specify in case of an error — when I explain initialization.)

Notice that some code is commented out in Listing 3-9. Here, if you've set in motion some sort of activity indicator or other kind of user feedback mechanism (I explain that when I explain initialization next), you comment out that code to turn it off. (I show you an example of that in the commented out code).

The use of selectors, both in the case of a successful download as well as in cases where downloads are unsuccessful, is what allows you to have several of these ServerManager objects working at the same time. Although it makes sense to serialize data downloads from a single site, it also may make sense to have several ServerManager objects that each access different sites.

Note

This whole process mirrors a pattern used by the iPhone OS that allows you to implement asynchronous processing. You came across this when you used the XML parser in Book V, so you shouldn't be surprised to see it again.

The only thing left to do (as I have been promising to show you) is initialization. (Think of this as the icing underneath the cherry.) Enter the code in Listing 3-9 to ServerManager.m.

Example 3-9. ServerManager Initialization

- (id) initWithModel:(Trip*)theModel  delegate:receiver
     sucessfulSelector:(SEL)sucessfulSelector
     connectionErrorSelector:(SEL)connectionErrorSelector{

  if (self = [super init]) {
    sucessful = sucessfulSelector;
    connectionError = connectionErrorSelector;
    delegate = [receiver retain];
    trip = theModel;
//  RoadTripAppDelegate *appDelegate = (RoadTripAppDelegate*)
    [[UIApplication sharedApplication] delegate];
//  UIApplication* application =
                       [UIApplication sharedApplication];
//  [appDelegate.activityIndicator startAnimating];
//  application.networkActivityIndicatorVisible = YES;
  }
  return self;
}

As you've seen, when you initialize a ServerManager instance, you need to tell it what to do in case of two scenarios — both success and failure. In the case of success, you'll perform the sucessfulSelector, and in the case of a failure the connectionErrorSelector. In the initialization method, you save these selectors for later use in connectionDidFinishLoading: or connection:didFailWithError, respectively.

Note

Again, you'll notice some code in Listing 3-9 that's commented out:

RoadTripAppDelegate *appDelegate = (RoadTripAppDelegate *)
             [[UIApplication sharedApplication] delegate];
UIApplication* application = [UIApplication
                                       sharedApplication];
[appDelegate.activityIndicator startAnimating];
application.networkActivityIndicatorVisible = YES;

This code is how you might start a progress indicator going to inform the user of what is going on. For this to work, you'd have to already had an outlet defined in the main window nib that referenced an activity indicator object that you added to the main screen in Interface Builder — I'll leave you to explore this on your own.

Alternatively, as I mentioned earlier, you might start downloads at application startup (on another thread — way, way out of scope for this book) and use an activity or progress indicator only when the user tries to do something that requires data you haven't yet downloaded.

One more thing

What I've been highlighting so far in this chapter with regard to dealing with downloads represents the simplest implementation of an application using NSURLConnection. There are some more delegate methods that provide the ability to customize the handling of server redirects, authorization requests, and caching of the response. But as I said, I don't use them here.

But one more thing I do want to explain is handling authentication challenges.

Although the server you are using for RoadTrip doesn't require a user name and password, there will probably be times when the server you want to use will in fact make that request of you. Although I have no need to implement that functionality here, Listing 3-10 shows an example of how you'd do that.

Example 3-10. connection:didReceiveAuthenticationChallenge Example

- (void)connection:(NSURLConnection *)connection
   didReceiveAuthenticationChallenge:
                  (NSURLAuthenticationChallenge *)challenge {

  if ([challenge previousFailureCount] != 0) {
    [connection cancel];
    UIAlertView *alert =
           [[UIAlertView alloc] initWithTitle:@"Yo!"

   message:NSLocalizedString (@" Your User Name
              and/or Password is incorrect",
                              @"No user name or password")
           delegate:self
           cancelButtonTitle:NSLocalizedString
                                    (@"Thanks", @"Thanks")
           otherButtonTitles:nil];
    [alert show];
    [alert release];
  }
  else {
    NSURLCredential *credential = [[NSURLCredential alloc]
       initWithUser:[[NSUserDefaults standardUserDefaults]
          stringForKey:kUserNamePreference]
          password:[[NSUserDefaults standardUserDefaults]
          stringForKey:kPasswordPreference]
          persistence:NSURLCredentialPersistenceNone];
[[challenge sender] useCredential:
           credential forAuthenticationChallenge:challenge];
      [credential release];
    }
  }

If a request requires authentication, your delegate receives a connection:didReceiveAuthenticationChallenge: message. At that point, you need to do one of the following:

  • Provide credentials to attempt to use for authentication.

  • Attempt to continue without credentials.

  • Cancel the authentication request.

In the case of Listing 3-10, the delegate has created an NSURLCredential object with the user name and password that are in the application's preferences as well as the type of persistence to use for the credentials, and then sends the [challenge sender] a useCredential:forAuthenticationChallenge: message:

NSURLCredential *credential = [[NSURLCredential alloc]
       initWithUser:[[NSUserDefaults standardUserDefaults]
          stringForKey:kUserNamePreference]
          password:[[NSUserDefaults standardUserDefaults]
          stringForKey:kPasswordPreference]
          persistence:NSURLCredentialPersistenceNone];
[[challenge sender] useCredential:
         credential forAuthenticationChallenge:challenge];
[credential release];

If the authentication has failed previously, it cancels the authentication challenge and informs the user:

if ([challenge previousFailureCount] != 0) {
  [connection cancel];
  UIAlertView *alert =
    [[UIAlertView alloc] initWithTitle:@"Yo!"
      message:NSLocalizedString (@" Your User Name
      and/or Password is incorrect",
                              @"No user name or password")
      delegate:self
      cancelButtonTitle:NSLocalizedString
                                    (@"Thanks", @"Thanks")
      otherButtonTitles:nil];
  [alert show];
  [alert release];
}

Implementing Sight with Asynchronous Downloads

In Chapter 2 of this minibook, you added Listing 2-12 to Sight.m so that you could make previously downloaded files from a server on the Internet available to the WebViewController and subsequently the WebView. Back then, I promised to explain some of the "stranger" code." Now that you understand the URL loading system, I can go back and do just that. If you haven't already done so, add the webViewControllerNSURLRequest:processMethod: method shown in Listing 3-11 to Sight.m.

Example 3-11. Implementing the webViewControllerNSURLRequest:processMethod: Delegate Method in Sight

- (void) webViewControllerNSURLRequest:(
           WebViewController *) controller
                     processMethod:(SEL)theProcessMethod {

  viewController = controller;
  processMethod = theProcessMethod;
  RoadTripAppDelegate *appDelegate =
       (RoadTripAppDelegate *)[[UIApplication
                             sharedApplication] delegate];
  BOOL realtime = !appDelegate.useStoredData;
  if (realtime) {
    NSString*  urlString = [[NSString alloc]
    initWithFormat:@"%@%@.%@", @"http://nealgoldstein.com/",
    resource,
                                         the resourceType];
     [self downloadData:urlString];
  }
  else {
    [self getData];
  }
}

As you recall, this is the delegate method that the WebViewController invokes in viewDidLoad:

[delegate webViewController:self
      nsurlRequestProcessMethod:@selector(loadWebView:)];

This bit of code starts the chain of events that ends with the WebViewController receiving the NSURLRequest in loadWebView: that it then passes on to its Web view.

In nsurlRequestProcessMethod::, the selector (loadWebView:) is saved in the processMethod instance variable.

processMethod = theProcessMethod;

The downloadData: message is then sent to self. Add the code in Listing 3-12 to Sight.m in order to get that little chore done.

Example 3-12. downloadData:

- (void)downloadData:(NSString*) urlString {

  sightServerManager = [[ServerManager alloc]
     initWithModel:trip delegate:self
     sucessfulSelector:@selector(saveData:)
     connectionErrorSelector:@selector(connectionError:)];
  [sightServerManager get:urlString];
}

This method allocates and initializes a ServerManager instance. It passes in a reference to the Trip, (which I don't use in this example) and two selectors. The first, sucessfulSelector, results in the ServerManager instance sending the saveData: message to Sight object when the file has been successfully downloaded. The second, connectionErrorSelector, results in the ServerManager instance sending the Sight object the connectionError: message if there's a connection failure.

After that's been done, the method sends the get: message to the ServerManager to get everything rolling.

Sending the right message

Now you can put things in gear by tackling the code behind the saveData:. message. Add what you see in Listing 3-13 to Sight.m.

Example 3-13. saveData:

- (void)saveData:(NSMutableData *) rxData {

    receivedData = rxData;
    [receivedData retain];
    NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocu
     mentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
    NSString *filePath = [documentsDirectory stringByAppendingP
     athComponent:sightName];
    NSURL* theFileURL = [[NSURL alloc]
     initFileURLWithPath:(NSString *)filePath];
    [receivedData writeToURL:theFileURL atomically:YES];
    NSURLRequest* theNSURLRequest = [NSURLRequest
                                 requestWithURL:theFileURL];
    [viewController performSelectorOnMainThread:processMethod
               withObject:theNSURLRequest waitUntilDone:NO];
[theFileURL release];
  [receivedData  release];
}

This method more or less does what saveCarServicingData: did in Chapter 4 of Book V.

In Book V, you get the path to the Documents directory.

NSArray *paths =
  NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,
                                   NSUserDomainMask, YES);
NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *filePath = [documentsDirectory
                stringByAppendingPathComponent:fileName];

On the iPhone, you really don't have much choice about where the file goes. Although there's a /tmp directory, I'm going to place this file in the Documents directory — because this is part of my application's sandbox (the part of the iPhone storage I can modify), so it's the natural home for all the app's files.

NSSearchPathForDirectoriesInDomains: returns an array of directories; because I'm only interested in the Documents directory, I use the constant NSDocumentDirectory — and because I'm restricted to my home directory/sandbox, the constant NSUserDomainMask limits the search to that domain. There will be only one directory in the domain, so the one I want will be the first one returned.

Continuing with the tour of Listing 3-13, you'll see that the next big step is creating the complete path by appending the path filename to the directory.

NSString *filePath = [documentsDirectory
                   stringByAppendingPathComponent:fileName];

stringByAppendingPathComponent; precedes the filename with a path separator (/) if necessary.

Tip

Unfortunately, this technique doesn't work if you're just trying to create a string representation of a URL.

When you implemented the method in saveCarServicingData: in Book V you wrote the data to the file using :

[dataLoaded writeToFile:filePath atomically:YES];

writeToFile: is an NSData method and does what it implies. I am actually telling the array here to write itself to a file, which is why I decided to save the location in this way in the first place.

A number of other classes can implement the writeToFile: method, including NSDate, NSNumber, NSString, and NSDictionary. (You can also add this behavior to your own objects, and they could save themselves — but I won't get into that here.) The atomically parameter first writes the data to an auxiliary file, and when that's successful, it's renamed to the path you've specified. This situation guarantees that the file won't be corrupted even if the system crashes during the write operation.

In this new implementation, things are pretty much the same, except you use

[receivedData writeToURL:theFileURL atomically:YES];

Because at present only file URLs are supported, there's no difference between this method and writeToFile:atomically:, except for the type of the first argument. You do this because it's the preferred method to use in the URL loading system and is likely to keep you compatible for some time in the future.

Finally saveData: continues to create an NSURLRequest and then perform the selector:

[viewController performSelectorOnMainThread:processMethod
             withObject:theNSURLRequest waitUntilDone:NO];

This final line of code completes the circle by performing the selector (which was passed in at ServerManager initialization in Listing 3-12 and saved in the processMethod instance variable) which results in the loadWebView: message (just as you did previously), passing in the NSURLRequest.

Next you need to implement the getData method if the user is in stored data mode. To do that, enter the code in Listing 3-14.

Example 3-14. getData

- (void)getData {

  NSArray *paths = NSSearchPathForDirectoriesInDomains
  (NSDocumentDirectory, NSUserDomainMask, YES);
  NSString *documentsDirectory = [paths objectAtIndex:0];
  NSString *filePath = [documentsDirectory
               stringByAppendingPathComponent:sightName];
  NSURL* theFileURL = [[NSURL alloc]
               initFileURLWithPath:(NSString *)filePath];
  NSURLRequest* theNSURLRequest = [NSURLRequest
                              requestWithURL:theFileURL];
  [viewController performSelectorOnMainThread:processMethod
   withObject:theNSURLRequest waitUntilDone:NO];
  [theFileURL release];
}

getData is essentially the same as saveData:, except it does not first save the data it received from the ServerManager download. Instead, it uses data that was previously saved in saveData:.

Just like what you see in saveData:, getData creates an NSURLRequest and then performs the selector.

[viewController performSelectorOnMainThread:processMethod
             withObject:theNSURLRequest waitUntilDone:NO];

Now you need to take care of the connectionError:: business. Enter the code in Listing 3-15 to Sight.m.

Example 3-15. In Case of an Error

- (void) connectionError:(NSError *)error {

  NSLog(@"Download NSURLConnection failed:%@ %@",
       [error localizedDescription], [[error userInfo]
                objectForKey:NSErrorFailingURLStringKey]);
  UIAlertView *alert = [[UIAlertView alloc]
        initWithTitle:@"There's been a connection failure"
         message:NSLocalizedString
           (@"Couldn't get data to display ",
           @"Couldn't get data to display")
         delegate:self
         cancelButtonTitle:NSLocalizedString
         (@"Thanks", @"Thanks") otherButtonTitles:nil];
  [alert show];
  [alert release];
}

This code logs the error and presents an alert to inform the user of a connection failure. Depending on your application, there might be other things you would want to do here as well.

Finally, you need to do some housekeeping. You have to import the ServerManager. So add

#import "ServerManager.h"

to Sight.m.

You'll also have to declare the methods you have added in the Sight interface. So add the following to Sight.h:

- (void) webViewController:(WebViewController*) controller
          nsurlRequestProcessMethod:(SEL)theProcessMethod;
- (void) downloadData:(NSString*) urlString;
- (void) saveData:(NSMutableData *) rxData;
- (void) getData;

Getting Trip in on the action

Because the model objects themselves are delegates, there's much less involvement in this generic controller implementation for Trip. Trip's sole job now is to return the delegate object depending on the user's selection. However, you should be aware that a user selection no longer takes place just in RootViewController, but will also take place after SightListController.

Make the modifications to Trip.h you see in Listing 3-16. (Remember that you add the bold and delete the strikethrough.)

Example 3-16. Some Changes to Trip.h

#import <MapKit/MapKit.h>
#import <Foundation/Foundation.h>
#import "WebViewController.h"


@class Weather;
@class CarServicing;
@class CarInformation;

@interface Trip :NSObject  <WebViewControllerDelegate>{

  NSString       *tripName;
  CarServicing   *carServicing;
  CarInformation *carInformation;
  Weather        *weather;
  NSMutableArray *sights;

}

@property (nonatomic, retain) NSMutableArray * sights;


- (id) initWithName:(NSString*) theTripName;
//- (void) returnWeather:(id) theViewController;
//- (void) returnCarServicingInformation:(id)
   theViewController;
//- (void) returnCarInformation:(id) theViewController;
- (CLLocationCoordinate2D) initialCoordinate;
- (NSString*) mapTitle;
- (NSArray*) createAnnotations;

- (id) returnSightDelegate:(int) index;
- (id) returnWeatherDelegate;
- (id) returnCarInformationDelegate;
- (id) returnCarServicingDelegate;

@end

As you can see, you'll be deleting the selectors you previously used and instead creating methods that return the right delegate to the WebViewController.

In Listing 3-17, make the changes to Trip.m to implement these new methods.

Example 3-17. Updating Trip.m

- (id) returnSightDelegate:(int) index{

  return [sights objectAtIndex:index];
}
- (id) returnWeatherDelegate {

  return weather;
}

- (id) returnCarInformationDelegate {

  return carInformation;
}

- (id) returnCarServicingDelegate {

  return carServicing;
}


/*
- (void) returnWeather:(id) theViewController {
  [theViewController loadWebView:[weather
   weatherRealtime]];
}

- (void) returnCarServicingInformation:(id)
   theViewController {

  [theViewController performSelectorOnMainThread:@
   selector(loadWebView:) withObject:[carServicing
   returnCarServicingInformation] waitUntilDone:NO];
}

- (void) returnCarInformation:(id) theViewController {
  [theViewController performSelectorOnMainThread:@
   selector(loadWebView:) withObject:[carInformation
   returnCarInformation] waitUntilDone:NO];
}
*/

Finally, there's the last remaining method you need in the SightListController — the one you did not implement in Chapter 1 of this minibook. Here's where you respond to the user selecting a particular sight (the Golden Gate Bridge, for example) by sending the Trip the message for the Sight selector and then passing it in when you initialize the WebViewController. Pay particular attention to the code I have highlighted in bold, which does that. Add all of the code in Listing 3-18 to SightListController.m.

Example 3-18. tableView:didSelectRowAtIndexPath:

- (void)tableView:(UITableView *)tableView
       didSelectRowAtIndexPath:(NSIndexPath *) indexPath {

  [tableView deselectRowAtIndexPath:indexPath
                                            animated:YES];
  UIViewController *targetController ;
  targetController = [[WebViewController alloc]
        initWithTrip:trip
       delegate:[trip returnSightDelegate:indexPath.row]
        webControl:YES
        title:NSLocalizedString(@"Sights", @"Sights") ];
  if (targetController) [[self navigationController] pushView
   Controller:targetController animated:YES];
  [targetController release];
}

Notice you've solved your problem of which Sight object to use, because the SightListController knows what Sight object was chosen in its tableView:didSelectRowAtIndexPath: method.

If you were to compile and build your project at this point, you'd find that any selection in the Sights list would work just fine.

However, if you were to select something like Servicing, you would be thrown into the debugger. That's because, to finish things up, you need to make the changes to CarServicing, CarInformation, and Weather that implement the delegate mechanism now used by the WebViewController (as well as implement the way you now access data in Sight). Read on to find out how to do that.

Adding Delegation and the URL Loading System to the Rest of the Model Objects

Because you've entirely changed the mechanism that results in model objects returning the NSURLRequest to the WebViewController, you'll have to go back and make those changes in your other model objects — CarServicing, CarInformation, and Weather (As you've probably noticed by this point, application development is often a case of taking a step back so you can take two steps forward.)

Start with CarServicing.

Its implementation of the URL Loading System and delegation will be very similar to the Sight implementation of the URL Loading System and delegation because both download data from a server.

Start by updating the CarServicing.h file with the bolded code in Listing 3-19. (Be sure to delete the stuff in strikethrough.)

Example 3-19. CarServicing.h

@class Trip;
@class ServerManager;
#import "WebViewController.h"

@interface CarServicing : NSObject
                             <WebViewControllerDelegate> {
  Trip* trip;
  ServerManager      *sightServerManager;
  NSMutableData      *receivedData;
  SEL processMethod;
  UIViewController   *viewController;
}
- (id) initWithTrip:(Trip*) aTrip;
//- (NSURLRequest*) returnCarServicingInformation;
//- (void) saveCarServicingData:(NSString*) fileName
   withDataURL:(NSURL*) url;
//- (NSURL*) getCarServicingData:(NSString*) fileName;

@end

For all practical purposes, this is the same approach as Sight.h, although some names were changed to protect the innocent.

To be consistent with Sight, you'll make the internal methods part of a category. I explain that in Chapter 1 of this minibook, so if it isn't quite obvious, go back and review it there.

Add the code in Listing 3-20 to CarServicing.m.

Example 3-20. CarServicing "Private" Methods

@interface CarServicing ()
//- (NSURL*) getSightData:(NSString*) fileName;
- (void) downloadData:(NSString*) urlString;
- (void) saveData:(NSMutableData *) rxData;
- (void) getData;
//- (void) saveSightData: (NSString*) fileName withDataURL:
   (NSURL*) url;
@end

Finally, update CarServicing.m by adding the code in Listing 3-21.

Example 3-21. CarServicing.m

- (void) webViewController:(WebViewController *)
           controller
           nsurlRequestProcessMethod:(SEL)theProcessMethod {

    viewController = controller;
    processMethod = theProcessMethod;
    RoadTripAppDelegate *appDelegate = (RoadTripAppDelegate *)
     [[UIApplication sharedApplication] delegate];
    BOOL realtime = !appDelegate.useStoredData;
    if (realtime) {
      NSString* urlString = [[NSString alloc]
        initWithFormat:@"%@%@.%@",
        @"http://nealgoldstein.com/", @"CarServicing",
     @"html"];
      [self downloadData:urlString];
    }
    else {
      [self getData];
    }
  }

  - (void)downloadData:(NSString*) urlString {
    sightServerManager = [[ServerManager alloc]
       initWithModel:trip delegate:self
       sucessfulSelector:@selector(saveData:)
       connectionErrorSelector:@selector(connectionError:)];
    [sightServerManager get:urlString];
  }

  - (void)saveData:(NSMutableData *) rxData {

    receivedData= rxData;
    [receivedData retain];
    NSArray *paths = NSSearchPathForDirectoriesInDomains
              (NSDocumentDirectory, NSUserDomainMask, YES);
    NSString *documentsDirectory = [paths objectAtIndex:0];
NSString *filePath = [documentsDirectory
          stringByAppendingPathComponent:@"CarServicing"];
  NSURL* theFileURL = [[NSURL alloc]
                initFileURLWithPath:(NSString *)filePath];
  [receivedData writeToURL:theFileURL atomically:YES];
  NSURLRequest* theNSURLRequest = [NSURLRequest
                               requestWithURL:theFileURL];
  [viewController performSelectorOnMainThread:processMethod
   withObject:theNSURLRequest waitUntilDone:NO];
  [theFileURL release];
  [receivedData release];
}

- (void)getData {

  NSArray *paths = NSSearchPathForDirectoriesInDomains
            (NSDocumentDirectory, NSUserDomainMask, YES);
  NSString *documentsDirectory = [paths objectAtIndex:0];
  NSString *filePath = [documentsDirectory
         stringByAppendingPathComponent:@"CarServicing"];
  NSURL* theFileURL = [[NSURL alloc]
               initFileURLWithPath:(NSString *)filePath];
  NSURLRequest* theNSURLRequest = [NSURLRequest
                              requestWithURL:theFileURL];
  [viewController performSelectorOnMainThread:processMethod
   withObject:theNSURLRequest waitUntilDone:NO];
  [theFileURL release];
}

- (void) connectionError:(NSError *)error {

  NSLog(@"Download NSURLConnection failed:%@ %@",
       [error localizedDescription], [[error userInfo]
                objectForKey:NSErrorFailingURLStringKey]);
  UIAlertView *alert = [[UIAlertView alloc]
        initWithTitle:@"There's been a connection failure"
         message:NSLocalizedString
           (@"Couldn't get data to display ",
           @"Couldn't get data to display")
         delegate:self
         cancelButtonTitle:NSLocalizedString
         (@"Thanks", @"Thanks") otherButtonTitles:nil];
  [alert show];
  [alert release];
}

You also need to delete the following methods from CarServicing.m.

- (NSURLRequest*) returnCarServicingInformation {...
- (void) saveCarServicingData:(NSString*) fileName
   withDataURL:(NSURL*) url  {...
- (NSURL*) getCarServicingData:(NSString*) fileName {...

You need to add a new #import statement to CarServicing.m as well.

#import "ServerManager.h"

Because the code here is substantially the same as Sight.m, I'm not going to explain what you have done here. The only thing I want you to pay attention to is the name of the file you save in saveData: and load in getData.

So, pay attention: In the case of Sight, you're using sightName as the filename. The sightName filename is passed to the Sight object when it's initialized and found in the property list. In the case of CarServicing, you are hard coding the filename. If this were a commercial application, you would have CarServicing defined in the property list where you would specify some kind of filename, or create a filename using some algorithm such as hash.

Finally, you need to update both CarInformation and Weather. Make the modifications shown in Listings 3-22 and 3-23 to CarInformation.h and .m, respectively.

Example 3-22. CarInformation.h

@class Trip;
#import "WebViewController.h"

@interface CarInformation : NSObject
                             <WebViewControllerDelegate> {
  Trip* trip;
  SEL processMethod;
  UIViewController* viewController;
}
- (id) initWithTrip:(Trip*) aTrip;
// - (NSURLRequest*) returnCarInformation;

@end

In CarInformation.m, because you can reuse some of the code, you just have to change the method as needed.

Example 3-23. CarInformation.m

//- (NSURLRequest*) returnCarInformation {
  - (void) webViewController:(WebViewController *)
           controller
           nsurlRequestProcessMethod:(SEL)theProcessMethod {
NSString *filePath = [[NSBundle mainBundle]
   pathForResource:@"CarSpecs" ofType:@"html"];
  NSURL* carInformationData= [NSURL
   fileURLWithPath:filePath];
  NSURLRequest* theNSURLRequest =
         [NSURLRequest requestWithURL:carInformationData];
// return theNSURLRequest;
  [controller performSelectorOnMainThread:theProcessMethod
   withObject:theNSURLRequest waitUntilDone:NO];
}

Because the data is local, there's no need to use the ServerManager. You're just adding the delegate and its implementation and deleting the method that created the NSURLRequest previously. In this particular implementation, you're deleting the return statement and instead sending the same performSelectorOnMainThread:withObject:waitUntilDone message as you did in both the Sight object's and CarServicing object's getData and SaveData: methods.

You also need to change Weather. Update Weather.h and .m with the code in Listings 3-24 and 3-25, respectively. You'll be making similar changes to the ones you made to CarInformation.

Example 3-24. Update Weather.h

#import "WebViewController.h"

@interface Weather : NSObject <WebViewControllerDelegate> {
}
// - (NSURLRequest*) weatherRealtime; //$$

@end

Example 3-25. Update Weather.m

#import "Weather.h"

@implementation Weather

// - (NSURLRequest*) weatherRealtime {
- (void) webViewController:(WebViewController *)
         controller
         nsurlRequestProcessMethod:(SEL)theProcessMethod {

  NSURL *url = [NSURL URLWithString:@"http://www.
   weather.com/outlook/travel/businesstraveler/local/
   UKXX0085?lswe=CarServicing,%20UNITED%20KINGDOM&lwsa=Weathe
   rLocalUndeclared&from=searchbox_typeahead"];
if (url == NULL) NSLog( @"Data not found %@", url);
  NSURLRequest* theNSURLRequest =
                       [NSURLRequest requestWithURL:url];
// return theNSURLRequest;
[controller performSelectorOnMainThread:theProcessMethod
             withObject:theNSURLRequest waitUntilDone:NO];
}

@end

Again, you're simply adding the delegate and its implementation and deleting the method that you'd previously used to create the NSURLRequest.

What About the Map?

In Book V, Chapter 5, I had you enter some code to display annotations. I mentioned then that while this did illustrate how to display annotations, this way of doing it was just that, an illustration.

In fact, the "right" way to do annotations is to give each model object the responsibility of displaying itself as an annotation. In this case, I'm talking about the Sight objects, and in the next chapter I show you how to do the same thing with Hotel objects.

Start with the Map object I had you create in Chapter 5 of in Book V. As you may have noticed, I never really had you do anything with it. Well, now you're going to put the Map object to work. You're going to give it a new job description: Manager of all things annotation-ish.

Add the bolded code in Listing 3-26 to Map.h.

Example 3-26. Map.h

@class Trip;

@interface Map : NSObject {

  NSMutableArray *annotations;
  Trip           *trip;
}

- (id) initWithTrip:(Trip*) theTrip;
@property (nonatomic, retain)
                           NSMutableArray * annotations;
- (void) createAnnotations;


@end

Aside from the (expected) initialization method, you've added an NSMutableArray to hold the annotation objects. You'll see how to use that shortly. You've also added a method that will be invoked when the mapContoller needs to get the annotations to display in the mapView.

You might be asking yourself why I'm telling you to add another method when you could have the Map create its own annotations when it's initialized. Well, you could do it that way if you knew all the annotations you'll need when the Map is created. But, as you'll see in Chapter 5 of this minibook, if you're going to want to add new items — like Hotels — while executing, you need a way to dynamically add more annotations to the map.

Now add the code in list 3-27 to Map.m.

Example 3-27. Map.m

#import "Map.h"
#import "Trip.h"
#import "Sight.h"

@implementation Map
@synthesize annotations;

- (id) initWithTrip:(Trip*) theTrip {

  if (self = [super init]) {
    trip = theTrip;
    annotations = [[NSMutableArray alloc]
                    initWithCapacity:[trip.sights count]];

  }
  return self;
}

- (void) createAnnotations {

  [annotations removeAllObjects];
  [annotations addObjectsFromArray:trip.sights ];

}

- (void)dealloc {

  [annotations release];
  [super dealloc];
}
@end

In the initWithTrip: method, you first create and initialize that NSMutableArray you declared based on the number of Sight objects Trip has created and added to the sights array.

annotations = [[NSMutableArray alloc]
                    initWithCapacity:[trip.sights count]];

Map passes this array on to the MapController, just like Trip did in Chapter 5 of Book V.

Next, you have to make some changes to MapController to get the annotations from Map.

Make the (bolded) modifications in Listing 3-28 to MapController.m.

Example 3-28. MapController.m

#import "Map.h"
- (id) initWithTrip:(Trip*) theTrip {

  if (self = [super initWithNibName:@"MapController"
   bundle:nil]) {
    trip = theTrip;
    map = trip.map;
    trip.mapController = self;
}
  return self;
}
- (void) refreshAnnotations {

  [mapView removeAnnotations:map.annotations];
  [map createAnnotations];
  [mapView addAnnotations:map.annotations];
}

Basically, all you've done here is assign an instance variable — map — so that MapController can get the annotations from Map. If you've been paying attention, you may wonder why I don't have the MapController get the map reference from Trip, since that's been my modus operandi with the model objects up till now. Why use Map as a Trip property instead?

Despite my ranting and raving, using Map as a Trip property is much easier and more convenient, and I wanted to use this opportunity to show you that. I'll leave it up to you to decide how you want to do things in your application.

You have also added a new method: refreshAnnotations. Although you won't actually be using this until Chapter 5 of this minibook, I have you find a home for it here.

Note

What refreshAnnotations will do for you is enable you to add new annotations during execution, such as when you add a new Hotel, for example. It first removes all the annotations for the mapView, then has the Map (re)create the annotations, and then adds the annotations back to the mapView. (This is why you needed to add the reference to map to mapController.)

Implementing refreshAnnotations will require changing how you add the annotations in viewDidLoad. You have to modify MapController to get the annotations array from Map.

Make the modifications to viewDidLoad in MapController.m in Listing 3-29.

Example 3-29. Modifying viewDidLoad

- (void)viewDidLoad {

  [super viewDidLoad];
  mapView.showsUserLocation = YES;
  CLLocationCoordinate2D initialCoordinate =
                                 [trip initialCoordinate];
  [self updateRegionLatitude:initialCoordinate.latitude
   longitude:initialCoordinate.longitude
                    latitudeDelta:.06 longitudeDelta:.06];
  self.title = [trip mapTitle];
  [mapView.userLocation addObserver:self
          forKeyPath:@"location"  options:0 context:NULL];
//annotations = [[NSMutableArray alloc]
   initWithCapacity:1];
 // [annotations addObjectsFromArray:[trip
   createAnnotations]];
 // [mapView addAnnotations:annotations];
  [trip.map createAnnotations];
  [mapView addAnnotations:trip.map.annotations];

  UIBarButtonItem *locateButton =
  [[UIBarButtonItem alloc] initWithTitle:@"Locate"
          style:UIBarButtonItemStylePlain target:self
                       action:@selector(goToLocation:)];
  self.navigationItem.rightBarButtonItem = locateButton;
  [locateButton release];
  NSMutableArray *items =
             [[NSMutableArray alloc] initWithCapacity:1];
    UIBarButtonItem *barButton =
      [[UIBarButtonItem alloc] initWithTitle:@"Find:"
      style:UIBarButtonItemStyleBordered
      target:self
      action:@selector(find:)];
  barButton.tag = 0;
  barButton.width = 100;
[items addObject:barButton];
    [barButton release];

    UIToolbar *toolbar = [UIToolbar new];
    toolbar.barStyle = UIBarStyleBlackOpaque;
    [toolbar sizeToFit];
    CGFloat toolbarHeight = toolbar.frame.size.height;
    CGFloat navbarHeight = self.navigationController.
     navigationBar.frame.
    size.height;
    CGRect mainViewBounds = self.view.bounds;
    [toolbar setFrame:
       CGRectMake(CGRectGetMinX(mainViewBounds),
                  CGRectGetMinY(mainViewBounds) +
                  CGRectGetHeight(mainViewBounds) −
                  (toolbarHeight + navbarHeight),
                  CGRectGetWidth(mainViewBounds),
                  toolbarHeight)];
    [self.view addSubview:toolbar];
    [toolbar release];
    toolbar.items = items;

  }

You also need to import Map in MapController.m:

#import "Map.h"

and no longer release annotations in dealloc:

- (void)dealloc {

   //[annotations release];
   [super dealloc];
}

Finally, because you're doing annotations differently, you have to modify doFind:, which adds annotations when you find a location on the map. In this case, I'll continue to have you use the MapAnnotation class, to implement the annotation for the found location.

But in this case you won't be getting Map involved in creating the annotation, so you'll simply have the mapView remove the annotations and then add the new annotation to the Map object's annotations array, and then (finally) add the annotations back to the mapView.

To do that, make the modifications in Listing 3-30 to doFind: in MapController.m.

Example 3-30. Modifications to doFind:

- (void) doFind:(NSString*) newLocation {

// [mapView removeAnnotations:annotations];
  [mapView removeAnnotations:map.annotations];

  Geocoder* geocoder = [[Geocoder alloc] init];
  CLLocationCoordinate2D locationCoordinate = [geocoder
   geocodeLocation:newLocation];
  MKCoordinateRegion initialRegion;
  initialRegion.center.latitude =
  locationCoordinate.latitude;
  initialRegion.center.longitude =
  locationCoordinate.longitude;
  initialRegion.span.latitudeDelta = .06;
  initialRegion.span.longitudeDelta = .06;
  [mapView setRegion:initialRegion animated:NO];
  MapAnnotation* findAnnotation = [[MapAnnotation alloc]
      initWithTitle:@"Found Location"
      subTitle:@"pretty easy"
      coordinate:locationCoordinate];
// [annotations addObject:findAnnotation];
  [map.annotations addObject:findAnnotation];

// [mapView addAnnotations:annotations];
  [mapView addAnnotations:map.annotations];
  [findAnnotation release];
}

You also have to make some changes to mapController.h to support the changes you just made. You'll need to add

@class Map;
Map* map;
- (void) refreshAnnotations;

You can delete the annotations array instance variable as well:

//  NSMutableArray       *annotations;

Notice an interesting consequence of these changes.

If you had an annotation for a find location, it disappears if you add a new annotation (although you won't be able to see that until Chapter 5 of this minibook).

This is because you create the found location annotation in mapController.m, and add it to the Map's annotations array.

[map.annotations addObject:findAnnotation];

But because Map, which now owns the annotations list, doesn't know anything about the found location; if you tell it to (re)create the annotations array, it's now gone.

I'll leave it as an exercise for you to do. You need to save the found location annotation when there is one and add it to the map only when it is there.

The next thing you need to do is have Trip create and initialize the Map object and fire Trip from its job of creating the annotations array.

Make the modifications to Trip.h, as shown in Listing 3-31.

Example 3-31. Modifications to Trip.h

#import <MapKit/MapKit.h>
#import <Foundation/Foundation.h>
@class Weather;
@class CarServicing;
@class CarInformation;
@class Map;
@class MapController;

@interface Trip : NSObject {

  NSString       *tripName;
  CarServicing   *carServicing;
  CarInformation *carInformation;
  Weather        *weather;
  NSMutableArray *sights;
  Map            *map;
  MapController  *mapController;

}

@property (nonatomic, retain) NSMutableArray *sights;
@property (nonatomic, retain) Map* map;
@property (nonatomic, retain) MapController
                                           *mapController;

- (id) initWithName:(NSString*) theTripName;
- (CLLocationCoordinate2D) initialCoordinate;
- (NSString*) mapTitle;
//- (NSArray*) createAnnotations;
- (id) returnSightDelegate:(int) index;
- (id) returnWeatherDelegate;
- (id) returnCarInformationDelegate;
- (id) returnCarServicingDelegate;

You've added the properties needed by MapController to access Map as well as allow Trip (in Chapter 5 of this minibook) to access MapController to tell it to refresh the annotations. You've also gotten rid of the createAnnotations method. Make the modifications to the Trip.m file's initWithName: to support that, as shown in Listing 3-32.

Example 3-32. Trip.m initwithname:

- (id) initWithName:(NSString*) theTrip {

  if ((self = [super init])) {
    tripName = theTrip;
    [tripName retain];
    carInformation = [[CarInformation alloc]
   initWithTrip:self];
    carServicing = [[CarServicing alloc] initWithTrip:self];
    weather = [[Weather alloc] init];
    [self loadSights];
    map =[[Map alloc] initWithTrip:self];
  }
  return self;
}

In Trip.m, that also means adding

#import "Map.h"
#import "MapController.h"

@synthesize map;
@synthesize mapController;

You also need to delete createAnnotations from Trip.m:

- (NSArray*) createAnnotations {

And finally, Sight.m; you need to implement the methods that will provide the title and subtitle information for a Sight. Add the code in Listing 3-33 to Sight.m.

Example 3-33. Implement title and subtitle

- (NSString*)title{

  return title;
}
- (NSString*)subtitle{

  return subtitle;
}

Where Are You?

At this point, you can compile and run RoadTrip. You'll be able to select a sight and get information about it, as well as see the sights on the Map view.

You've come a long way in adding data management to your RoadTrip application — but you aren't done yet. In the next chapter, you explore Core Data and see how easy it becomes to add and manage new Hotels on your road trip (and display them on the map as well).

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.222.182.66