Creating an App Update Notifier using Combine and async/await

An app update-notifier promotes a new app update to your users. Using a notifier will get better adoption rates with several benefits, like dropping old APIs and better conversion rates.

In this article, I’ll demonstrate how to create your update-notifier without using any remote configuration. The latest update will be fetched based on your app’s bundle identifier and promoted to the user if needed. I’ll be sharing an implementation using Combine, as well as using async/await.

Just looking for the code solution? All code from this article is open-sourced in a new framework called AppUpdately

Deciding to create your own framework

Creating an app update-notifier is a common practice when developing apps. There’s a framework called Siren that comes with many customization options. Yet, I still decided to create my framework for a few reasons.

You’ll learn a lot from developing your own solutions

Thinking about the solution and writing your business logic to solve a problem is extremely valuable for becoming a better engineer. It’s easy to add an external framework, but in a way, it’s similar to cheating to get the answer you need.

Fitting your needs

Frameworks that already exist are often not exactly what you need. They either do way too much for the simple functionality you need or don’t offer that single feature you were looking for. In many cases, it might be better to contribute to the existing project itself, especially if it’s a popular, well-maintained framework. However, if you want to gain complete control over the implementation, you might be better off creating your framework.

In my case, I was developing an update notifier for RocketSim on macOS. The earlier mentioned framework Siren does not support macOS and comes with integrated alerts to notify users. While this is great, it was not the functionality I was looking for. Instead of adding macOS support to Siren, I wrote my own simpler update-notifier with only the basic functionality I needed.

There’s room for competition

I hear this all the time:

Somebody already wrote about this topic

And it’s the same with creating your framework:

There’s already a framework doing exactly this

Yet, I fully believe there’s room for competition and your perspective. We’ve been in a world with ReactiveSwift, RxSwift, and Combine, which all have significant adoption. Many developers mean many opinions with different tastes. If you feel like you want to write your solution, I bet there’s someone out there that will prefer to use your framework over another.

A bundle identifier based App Update Notifier

Over the past years, I’ve seen several update-notifier solutions that often used a remote config. The remote configuration would tell the application whether an update was available based on a given input version identifier. While this works great, it does require you to set up a remote config integration which you might not always have at hand.

Instead, we can use the iTunes lookup API endpoint, which gives us the latest app metadata for a given bundle identifier. For example, this is the URL to get the newest information for RocketSim: https://itunes.apple.com/br/lookup?bundleId=com.swiftlee.rocketsim. The endpoint returns a JSON response with lots of data:

{
	"resultCount": 1,
	"results": [{
		"screenshotUrls": [
			"https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/ac/09/df/ac09df7b-b6fb-4c2b-3541-1f338a256579/46dcffb8-686b-417d-b595-c3a9b4fd7329_compare_split.png/800x500bb.jpg",
			"https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/d9/a6/0b/d9a60b28-9dc0-200f-c8fe-9ba9f0913c0d/7ba647e2-c90c-4d48-a8bb-247f161a63be_compare_overlay.png/800x500bb.jpg",
			"https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/11/fa/df/11fadf33-3c7d-f300-a994-cdd48052f46a/7ff213fb-3991-4a20-b674-ae6c8c514467_Recording.png/800x500bb.jpg",
			"https://is1-ssl.mzstatic.com/image/thumb/PurpleSource124/v4/d9/a1/87/d9a1879a-de8b-c2c7-2432-35c5b9e64341/b1dc7411-9d4f-4884-8beb-272531af27f5_locations.png/800x500bb.jpg"
		],
		"artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple116/v4/34/17/3f/34173f04-70b8-bc57-eede-cfdf85b8d7f5/source/60x60bb.png",
		"artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple116/v4/34/17/3f/34173f04-70b8-bc57-eede-cfdf85b8d7f5/source/512x512bb.png",
		"artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple116/v4/34/17/3f/34173f04-70b8-bc57-eede-cfdf85b8d7f5/source/100x100bb.png",
		"artistViewUrl": "https://apps.apple.com/br/developer/a-j-van-der-lee/id955259228?mt=12&uo=4",
		"kind": "mac-software",
		"minimumOsVersion": "11.0",
		"trackCensoredName": "RocketSim for Xcode",
		"languageCodesISO2A": ["EN"],
		"fileSizeBytes": "6942612",
		"sellerUrl": "https://www.rocketsim.app",
		"formattedPrice": "Grátis",
		"contentAdvisoryRating": "+4",
		"averageUserRatingForCurrentVersion": 0,
		"userRatingCountForCurrentVersion": 0,
		"averageUserRating": 0,
		"trackViewUrl": "https://apps.apple.com/br/app/rocketsim-for-xcode/id1504940162?mt=12&uo=4",
		"trackContentRating": "+4",
		"currency": "BRL",
		"bundleId": "com.swiftLee.RocketSim",
		"trackId": 1504940162,
		"trackName": "RocketSim for Xcode",
		"releaseDate": "2020-04-22T07:00:00Z",
		"isVppDeviceBasedLicensingEnabled": true,
		"primaryGenreName": "Developer Tools",
		"genreIds": ["6026", "6002"],
		"sellerName": "A.J. van der Lee",
		"currentVersionReleaseDate": "2021-10-28T17:03:24Z",
		"releaseNotes": "• We've updated the subscription system to be more reliable.",
		"primaryGenreId": 6026,
		"version": "5.2.2",
		"wrapperType": "software",
		"description": "RocketSim enhances the simulator with extra functionality like comparing designs, creating recordings into GIF or MP4, and launching deeplinks/universal links. etc..",
		"artistId": 955259228,
		"artistName": "A.J. van der Lee",
		"genres": ["Para desenvolvedores", "Utilidades"],
		"price": 0.00,
		"userRatingCount": 0
	}]
}

I’m purposely sharing the complete response in this article as I’m sure it will spark ideas. In this article, I will focus on the version and track view URL only, but you can write your solution later, which also shows the latest release notes.

Creating an update status fetcher

Our solution will make use of an UpdateStatusFetcher which will return an enum describing the current app’s update status. You can follow along in this article or use the open-sourced version of this article: AppUpdately.

We start by creating the fetcher:

public struct UpdateStatusFetcher {
    public enum Status: Equatable {
        case upToDate
        case updateAvailable(version: String, storeURL: URL)
    }

    let url: URL
    private let bundleIdentifier: String
    private let decoder: JSONDecoder = JSONDecoder()
    private let urlSession: URLSession

    public init(bundleIdentifier: String = Bundle.main.bundleIdentifier!, urlSession: URLSession = .shared) {
        url = URL(string: "https://itunes.apple.com/br/lookup?bundleId=\(bundleIdentifier)")!
        self.bundleIdentifier = bundleIdentifier
        self.urlSession = urlSession
    }

    // .. further details
}

The fetcher is testable by allowing to overwrite the bundle identifier and URLSession. We configure the URL that we need later for fetching the latest metadata and a decoder to decode the JSON. The Status enum will allow us to return a describing result after fetching the metadata.

Fetching the metadata using Combine

Our first fetch implementation is going to make use of Combine. If you’re new to Combine, you might want to read my article Getting started with the Combine framework in Swift.

We start by defining the response models:

// A list of App metadata with details around a given app.
struct AppMetadata: Codable {
    /// The URL pointing to the App Store Page.
    /// E.g: https://apps.apple.com/br/app/rocketsim-for-xcode/id1504940162?mt=12&uo=4
    let trackViewUrl: URL

    /// The current latest version available in the App Store.
    let version: String
}

struct AppMetadataResults: Codable {
    let results: [AppMetadata]
}

We can use these models later for decoding the JSON response data. Followed next is a method to determine the app update status:

private func updateStatus(for appMetadata: AppMetadata) throws -> Status {
    guard let currentVersion = currentVersionProvider() else {
        throw UpdateStatusFetcher.FetchError.bundleShortVersion
    }

    switch currentVersion.compare(appMetadata.version) {
    case .orderedSame, .orderedDescending:
        return UpdateStatusFetcher.Status.upToDate
    case .orderedAscending:
        return UpdateStatusFetcher.Status.updateAvailable(version: appMetadata.version, storeURL: appMetadata.trackViewUrl)
    }
}

We compare our current version with the latest version as Strings by making use of the compare method. A current version provider callback returns the current app version, which will make it easier to write tests later:

var currentVersionProvider: () -> String? = {
    Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
}

Finally, we can put everything together by creating a chain of Combine operators:

public func fetch(_ completion: @escaping (Result<Status, Error>) -> Void) -> AnyCancellable {
    urlSession
        .dataTaskPublisher(for: url)
        .map(\.data)
        .decode(type: AppMetadataResults.self, decoder: decoder)
        .tryMap({ metadataResults -> Status in
            guard let appMetadata = metadataResults.results.first else {
                throw FetchError.metadata
            }
            return try updateStatus(for: appMetadata)
        })
        .sink { completionStatus in
            switch completionStatus {
            case .failure(let error):
                print("Update status fetching failed: \(error)")
                completion(.failure(error))
            case .finished:
                break
            }
        } receiveValue: { status in
            print("Update status is \(status)")
            completion(.success(status))
        }
}

We make use of our earlier configured URLSession instance and fetch the latest app metadata using the endpoint URL. We try to decode the result into an update status which will be returned through the completion callback to act as a functional app update-notifier.

The final implementation looks as follows when used:

UpdateStatusFetcher().fetch { result in
    switch result {
    case .success(let status):
        print(status) // Prints e.g. .upToDate
    case .failure(let error):
        print("Fetching failed: \(error)")
    }
}

Our fetch method returns a Combine cancellable to cancel the request if needed. You need to retain this cancellable to prevent the request from being canceled early on. To see how this works, we can write a unit test.

Testing the app update fetcher

When I’m writing my tests, I always like to create reusable code which will result in simple defined testing methods. To do this, we start by creating the test case:

import XCTest
import Mocker // https://github.com/WeTransfer/Mocker

final class UpdateStatusFetcherTests: XCTestCase {

    private var fetcher: UpdateStatusFetcher!
    private let mockedTrackViewURL = URL(string: "https://apps.apple.com/br/app/rocketsim-for-xcode/id1504940162?mt=12&uo=4")!

    override func setUpWithError() throws {
        try super.setUpWithError()

        let configuration = URLSessionConfiguration.default
        configuration.protocolClasses = [MockingURLProtocol.self]
        let urlSession = URLSession(configuration: configuration)
        fetcher = UpdateStatusFetcher(bundleIdentifier: "com.swiftlee.rocketsim", urlSession: urlSession)
    }

    override func tearDownWithError() throws {
        try super.tearDownWithError()
        fetcher = nil
    }
    
    // .. further details
}

The setup method is configuring a fetcher on every test run by passing in a mocked bundle identifier and URLSession. We mock the URL requests using a technique described in my article How to mock Alamofire and URLSession requests in Swift.

Up next is creating a few convenience methods that allow us to set up mocking for a specific test:

extension UpdateStatusFetcherTests {
    func mock(currentVersion: String, latestVersion: String) {
        fetcher.currentVersionProvider = {
            currentVersion
        }
        Mock(url: fetcher.url, dataType: .json, statusCode: 200, data: [.get: mockedResult(for: latestVersion).jsonData])
            .register()
    }

    func mockedResult(for version: String) -> AppMetadataResults {
        AppMetadataResults(results: [
            .init(trackViewUrl: mockedTrackViewURL, version: version)
        ])
    }
}

public extension Encodable {
    /// Returns a `Data` representation of the current `Encodable` instance to use for mocking purposes. Force unwrapping as it's only used for tests.
    var jsonData: Data {
        let encoder = JSONEncoder()
        return try! encoder.encode(self)
    }
}

The first method configures the fetcher with the current version and creates a mock for the expected outgoing endpoint. Another convenience method creates the expected JSON response, which we can use to compare our current version with.

To simplify assertions in our unit tests, we’re going to create our custom assert method:

extension UpdateStatusFetcherTests {
    func XCTAssertStatusEquals(_ expectedStatus: UpdateStatusFetcher.Status, line: UInt = #line) {
        let expectation = expectation(description: "Status should be fetched")
        let cancellable = fetcher.fetch { result in
            switch result {
            case .success(let status):
                XCTAssertEqual(status, expectedStatus, line: line)
            case .failure(let error):
                XCTFail("Fetching failed with \(error)", line: line)
            }
            expectation.fulfill()
        }
        addTeardownBlock {
            cancellable.cancel()
        }
        wait(for: [expectation], timeout: 10.0)
    }
}

This method demonstrates how you can retain the returned cancellable and make sure it’s cleaned on teardown. By making use of the #line argument, we’re making sure that any assertions thrown become visible within the actual running unit test. The method performs the fetch and makes sure the outcome matches our expectations.

The final test implementations will look relatively compact since they reuse all the earlier defined logic:

func testSameVersion() {
    mock(currentVersion: "2.0.0", latestVersion: "2.0.0")
    XCTAssertStatusEquals(.upToDate)
}

func testNewerVersion() {
    mock(currentVersion: "3.0.0", latestVersion: "2.0.0")
    XCTAssertStatusEquals(.upToDate)
}

func testOlderVersion() {
    mock(currentVersion: "1.0.0", latestVersion: "2.0.0")
    XCTAssertStatusEquals(.updateAvailable(version: "2.0.0", storeURL: mockedTrackViewURL))
}

This technique demonstrates how you can write clean unit tests that are easy to digest. You can find the full implementation of this code here.

Creating an async/await alternative

Using Combine is excellent, but what if you want to use async/await instead? Before diving in, make sure to read my article Async await in Swift explained with code examples.

The async/await alternative looks as follows:

@available(macOS 12.0, *)
public func fetch() async throws -> Status {
    let data = try await urlSession.data(from: url).0
    let metadataResults = try decoder.decode(AppMetadataResults.self, from: data)
    guard let appMetadata = metadataResults.results.first else {
        throw FetchError.metadata
    }
    return try updateStatus(for: appMetadata)
}

We’re reusing the earlier defined logic, which makes it easier to write this alternative fetching method without reusing our Combine fetching method. This is great as it allows us to quickly drop the Combine fetching method in the future once we want to rely on async/await entirely.

When writing our tests, we’ll notice the same benefit of reusing earlier defined code:

func testSameVersionAsync() async throws {
    mock(currentVersion: "2.0.0", latestVersion: "2.0.0")
    let status = try await fetcher.fetch()
    XCTAssertEqual(status, .upToDate)
}

func testNewerVersionAsync() async throws {
    mock(currentVersion: "3.0.0", latestVersion: "2.0.0")
    let status = try await fetcher.fetch()
    XCTAssertEqual(status, .upToDate)
}

func testOlderVersionAsync() async throws {
    mock(currentVersion: "1.0.0", latestVersion: "2.0.0")
    let status = try await fetcher.fetch()
    XCTAssertEqual(status, .updateAvailable(version: "2.0.0", storeURL: mockedTrackViewURL))
}

We make use of the earlier defined mock convenience method, and we compare the outcome accordingly.

Conclusion

Writing your app update-notifier can be a fun practice to learn and explore new techniques. Don’t let other already available frameworks stop you from exploring new perspectives by writing your fitting code solution. Feel free to contribute your ideas to AppUpdately if you like, or of course, write your own!

If you like to learn more tips on Swift, check out the swift category page. Feel free to contact me or tweet me on Twitter if you have any additional suggestions or feedback.

Thanks!