Watch App Development Blog – Week 6

 I’m blogging my progress in developing an Apple Watch App. Read the previous instalments here.

Telstra – grrr!

I had a frustrating week trying to track down an odd issue – I wouldn’t get any train times load when I was walking around (i.e. near the station, where I want to test), but there was nothing showing up in the logs. The app would work perfectly at home when plugged into Xcode. This put a bit of a dampener on the planned Pebble app field testing. Eventually I managed to log an error message (NSURLErrorDomain -1003 – hostname not found). It turns out the tutum.io subdomain assigned to my docker endpoint isn’t resolving on any of Telstra’s DNS servers. I’m running Google DNS at home, which resolves the hostname fine, and so doesn’t exhibit the issue, but on 4G I’m at the mercy of the Telstra DNS. After an hour on the phone with Telstra, this has been ‘escalated to Level 3 support’ and I’ll get a response ‘within 7 days’. I’ve switched back to an IP address so I can continue my testing.

What did we learn here?

While I’m obviously shocked that Telstra’s internet services could be anything less than spectacular, the key takeaway is that leaving logging out of your code DOESN’T SAVE TIME. Swift makes it trivially easy to return a typesafe, idiot-proof error result that forces you to think critically about how you’re managed error conditions in your code. Consider this snippet (the culprit):

func get<T: JSONConvertible>(path: String, f: [T] ->()) {
    Alamofire.request(.GET, hostname + path)
        .responseJSON { (_, _, json, _) in
        if let json = json as? [NSDictionary] {
            f(json.map({ T(dictionary: $0) }))
        } else {
            f([])
        }
    }
}

func getAllStations(f: [Station] -> ()) {
    get("/train", f)
}
  1. I’m ignoring the error object that Alamofire is helpfully returning (the last parameter in responseJSON)
  2. I’m returning an empty array if literally anything goes wrong. Networking error, HTTP error (like a 500 from the server), malformed JSON payload, the lot = empty array.

In this case the empty station array was overwriting the locally cached stations, and my ‘nearestStation’ method was never producing a result. Because it’s just refreshing a local cache of fairly static data, I can safely ignore errors if I already have data. This could be done by testing the array count in the callback, but we should be able to do better.

Let’s change our ApiClient get method to the following:

enum Result<T> {
    case Value(Box<T>)
    case Error(NSError)
}

func get<T: JSONConvertible>(path: String, f: Result<[T]> ->()) {
    Alamofire.request(.GET, hostname + path)
        .responseJSON { (_, _, json, error) in
        if let json = json as? [NSDictionary] {
            f(Result.Value(Box(json.map({ T(dictionary: $0) }))))
        } else if let error = error {
            f(Result.Error(error))
        } else {
            f(Result.Error(NSError(domain: ApiClientErrorDomain,
                code: 1,
                userInfo: [NSLocalizedDescriptionKey: "An unknown error occurred."])))
        }
    }
}

We’re defining a Result type that returns either the requested value, or an NSError. Ignore the Box, this is only a figment of your imagination, and is totally not a hack to work around the Swift compiler’s problems with ‘non-fixed multi-payload enum layouts’. Note I’m also creating an ‘unknown error’ to handle the case where I don’t get back an error object from Alamofire. Just in case.

I use the updated API as follows:

        getAllStations { r in
            switch r {
            case let .Value(s):
                setStations(s.unbox)
            case let .Error(e):
                println("Error refreshing stations from the server: \(e.localizedDescription)")
            }
        }

So we have glorious logging in case of error and a rather annoying ‘unbox’ call. Importantly though, due to the signature of the API, it’s now much harder to lazily ignore error handling.

Advertisements

Watch App Development Blog – Week 3

In weeks 1 & 2, I got a Transperth-scraping REST API built and deployed to an AWS-based cloud host. This week I’ll get started on:

Step 3: The iPhone App

Third party apps on Apple Watch are very limited and rely heavily on the companion iPhone app for logic, network access, location etc, so the first place to start with the Watch app is on the iPhone.

The phone app itself will need useful functionality otherwise it’s unlikely to be approved. I have a few ideas for cool features for the phone, but for the moment it can just display the live times for the nearest station. This will require:

  1. Getting the list of stations from the server
  2. Finding the nearest station based on the user’s current location
  3. Getting the live times for that station from the server.

Let’s get started. I’m going to be building the app in Swift (of course). Mattt Thompson of NSHipster/AFNetworking fame has written a Swift-only networking framework called Alamofire, so we’ll start with it.

After setting up the framework following the instructions, I created a new Swift file called ‘ApiClient’. Downloading JSON from the server using Alamofire looks like the following:

func getAllStations(f: [Station] -> ()) {
    Alamofire.request(.GET, "\(hostname)/train")
        .responseJSON { (_, _, json, _) in
           if let json = json as? [NSDictionary] {
                let s = json.map({ Station(dictionary: $0) })
                f(s)
            } else { f([]) }
    }
}

struct Station {
    let id : String
    let name : String
    let location: CLLocation

    init(dictionary: NSDictionary) {
        self.id = dictionary["id"] as String
        self.name = dictionary["name"] as String
        let lat = Double(dictionary["lat"] as NSNumber)
        let long = Double(dictionary["long"] as NSNumber)
        self.location = CLLocation(latitude: lat, longitude: long)
    }
}

I’m using a global function (to be more functional) with a callback parameter that takes an array of stations. The returned JSON array is mapped over to convert the NSDictionary instances into Station values. If anything goes wrong, an empty array is passed to the callback – this isn’t brilliant error handling and will probably change, but it’s clearer to show as-is for now.

The user’s location can then be retrieved from a CLLocationManager, and the nearest location calculated like so:

    func nearestStation(loc: CLLocation) -> Station? {
        return stations.filter({ $0.distanceFrom(loc) <= 1500 }).sorted({ $0.distanceFrom(loc) &< $1.distanceFrom(loc) }).first
    }

    // where distanceFrom is defined on Station as
    func distanceFrom(otherLocation: CLLocation) -> CLLocationDistance {
        return location.distanceFromLocation(otherLocation)
    }

This will filter out all stations greater than 1.5km away, and return the nearest of the remainder (or nil if there are no nearby stations).

From there, we can retrieve the live times from the server using the code:

func getLiveTrainTimes(station: String, f: [Departure] -> ()) {
    Alamofire.request(.GET, "\(hostname)/train/\(station)")
        .responseJSON { (_, _, json, _) in
            if let json = json as? [NSDictionary] {
                let s = json.map({ Departure(dictionary: $0) })
                f(s)
            } else { f([]) }
    }
}

Wait – this looks pretty much identical to getAllStations, just with a different URL and return type. Let’s refactor:

protocol JSONConvertible {
    init(dictionary: NSDictionary)
}

func get<T: JSONConvertible>(path: String, f: [T] ->()) {
    Alamofire.request(.GET, hostname + path)
        .responseJSON { (_, _, json, _) in
        if let json = json as? [NSDictionary] {
            f(json.map({ T(dictionary: $0) }))
        } else { f([]) }
    }
}

// we can then redefine the other request methods as:
func getAllStations(f: [Station] -> ()) {
    get("/train", f)
}

func getLiveTrainTimes(station: String, f: [Departure] -> ()) {
    get("/train/\(station)", f)
}

The UI for the app (which I won’t go through here) is just a regular UITableView with a row for each train at the nearest station. However, it’s worth considering how the app will operate at different phases of its lifecycle – I want the live times to be updated in the background (for reasons that will become apparent later).

While the app is in the foreground, the location updates will come through as normal – I have a 50m distance filter on as this is unlikely to change your nearest station. When entering the background, the app will switch to the ‘significant change’ location service – again, accuracy is not super important so the course-grained significant change should work fine.

For the live train times network requests, in the foreground these will be triggered by a 30s NSTimer, in the background using the background fetch API. I haven’t used background fetch before, but it seems like the right technology to use in the case – allow the OS to decide if the app should refresh its data, based on battery life, network connectivity, and usage patterns of the app.

The various services are switched on & off like so:

    func applicationDidBecomeActive(application: UIApplication) {
        locationManager.startUpdatingLocation()
        timer = NSTimer.scheduledTimerWithTimeInterval(NSTimeInterval(30), target: self, selector: "timerFired:", userInfo: nil, repeats: true)
        timer?.fire()
    }

    func applicationWillResignActive(application: UIApplication) {
        locationManager.stopUpdatingLocation()
        timer?.invalidate()
        timer = nil
    }

    func applicationDidEnterBackground(application: UIApplication) {
        locationManager.startMonitoringSignificantLocationChanges()
    }

    func applicationWillEnterForeground(application: UIApplication) {
        locationManager.stopMonitoringSignificantLocationChanges()
    }

This is enough to start road-testing the app functionality out on the phone, and maybe start formulating a few ideas around the best functionality to prioritise on the watch. Speaking of the watch, next week I’ll have a surprise along those lines. As before, the code is available on bitbucket.