Skip to content

Instantly share code, notes, and snippets.

@diamantidis
Created April 12, 2020 09:00
Show Gist options
  • Select an option

  • Save diamantidis/fa7a5d4dce336e5b8f83827cb229fa9c to your computer and use it in GitHub Desktop.

Select an option

Save diamantidis/fa7a5d4dce336e5b8f83827cb229fa9c to your computer and use it in GitHub Desktop.
Notify users for app update on an iOS app
import Foundation
struct iTunesInfo: Decodable {
var results: [AppInfo]
}
struct AppInfo: Decodable {
var version: Version
var currentVersionReleaseDate: Date
}
struct Version: Decodable {
// MARK: - Enumerations
enum VersionError: Error {
case invalidFormat
}
// MARK: - Public properties
let major: Int
let minor: Int
let patch: Int
// MARK: - Initializers
init(from decoder: Decoder) throws {
do {
let container = try decoder.singleValueContainer()
let version = try container.decode(String.self)
try self.init(from: version)
} catch {
throw VersionError.invalidFormat
}
}
init(from version: String) throws {
let versionComponents = version.components(separatedBy: ".").map { Int($0) }
guard versionComponents.count == 3 else {
throw VersionError.invalidFormat
}
guard let major = versionComponents[0], let minor = versionComponents[1],
let patch = versionComponents[2] else {
throw VersionError.invalidFormat
}
self.major = major
self.minor = minor
self.patch = patch
}
}
import Foundation
class AppUpdateManager {
// MARK: - Enumerations
enum Status {
case required
case optional
case noUpdate
}
// MARK: - Initializers
init(bundle: BundleType = Bundle.main) {
self.bundle = bundle
}
// MARK: - Public methods
func updateStatus(for bundleId: String) -> Status {
// Get the version of the app
let appVersionKey = "CFBundleShortVersionString"
guard let appVersionValue = bundle.object(forInfoDictionaryKey: appVersionKey) as? String else {
return .noUpdate
}
guard let appVersion = try? Version(from: appVersionValue) else {
return .noUpdate
}
// Get app info from App Store
let iTunesURL = URL(string: "http://itunes.apple.com/lookup?bundleId=\(bundleId)")
guard let url = iTunesURL, let data = NSData(contentsOf: url) else {
return .noUpdate
}
// Decode the response
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
guard let response = try? decoder.decode(iTunesInfo.self, from: data as Data) else {
return .noUpdate
}
// Verify that there is at least on result in the response
guard response.results.count == 1, let appInfo = response.results.first else {
return .noUpdate
}
let appStoreVersion = appInfo.version
let releaseDate = appInfo.currentVersionReleaseDate
let oneWeekInSeconds: TimeInterval = 7 * 24 * 60 * 60
let dateOneWeekAgo = Date(timeIntervalSinceNow: -oneWeekInSeconds)
// Decide if it's a required or optional update based on the release date and the version change
if case .orderedAscending = releaseDate.compare(dateOneWeekAgo) {
if appStoreVersion.major > appVersion.major {
return .required
} else if appStoreVersion.minor > appVersion.minor {
return .optional
} else if appStoreVersion.patch > appVersion.patch {
return .optional
}
}
return .noUpdate
}
// MARK: - Private properties
private let bundle: BundleType
}
import Foundation
protocol BundleType {
func object(forInfoDictionaryKey key: String) -> Any?
}
extension Bundle: BundleType {}
extension UIApplication {
func openAppStore(for appID: String) {
let appStoreURL = "https://itunes.apple.com/app/\(appID)"
guard let url = URL(string: appStoreURL) else {
return
}
DispatchQueue.main.async {
if self.canOpenURL(url) {
self.open(url)
}
}
}
}
extension UIAlertController {
convenience init?(for status: AppUpdateManager.Status) {
if case .noUpdate = status {
return nil
}
self.init()
self.title = "App Update"
let updateNowAction = UIAlertAction(title: "Update now", style: .default) { _ in
let appId = "idXXXXXXXXXX" // Replace with your appId
UIApplication.shared.openAppStore(for: appId)
}
self.addAction(updateNowAction)
if case .required = status {
self.message = "You have to update the app."
} else if case .optional = status {
self.message = "There is a new version of the app."
let cancelAction = UIAlertAction(title: "Not now", style: .cancel)
self.addAction(cancelAction)
}
}
}
import UIKit
...
let bundleId = "com.example.yourapp"
let appUpdater = AppUpdateManager()
let updateStatus = appUpdater.updateStatus(for: bundleId)
if let alertController = UIAlertController(for: updateStatus) {
self.present(alertController, animated: true)
}
...
import Foundation
/// Class to provide utility functions for unit tests
class TestHelper {
static func inject<T>(into classType: T.Type, value: NSData) {
let selector = #selector(NSData.init(contentsOf:))
guard let originalMethod = class_getInstanceMethod(NSData.self, selector) else {
fatalError("\(selector) must be implemented")
}
let swizzledBlock: @convention(block) () -> NSData = {
return value
}
let swizzledIMP = imp_implementationWithBlock(unsafeBitCast(swizzledBlock, to: AnyObject.self))
method_setImplementation(originalMethod, swizzledIMP)
}
}
import XCTest
@testable import App_Update_Demo
class AppUpdateManagerTests: XCTestCase {
// MARK: - Overriden methods
override func setUpWithError() throws {
bundle = MockBundle()
sut = AppUpdateManager(bundle: bundle)
}
override func tearDownWithError() throws {
bundle = nil
sut = nil
}
// MARK: - Test methods
func testUpdateStatus_WithPatchIncrAndOlderThan7Days_ShouldReturnOptional() throws {
// Arrange
let test = """
{
"resultCount": 1,
"results": [
{
"currentVersionReleaseDate": "2019-12-31T09:41:00Z",
"version": "1.0.2"
}
]
}
"""
TestHelper.inject(into: NSData.self, value: test.data(using: .utf8)! as NSData)
bundle.version = "1.0.1"
// Act
let response = sut.updateStatus(for: "test")
// Assert
XCTAssertEqual(AppUpdateManager.Status.optional, response)
}
func testUpdateStatus_WithMajorIncrAndOlderThan7Days_ShouldReturnRequired() throws {
// Arrange
let test = """
{
"resultCount": 1,
"results": [
{
"currentVersionReleaseDate": "2019-12-31T09:41:00Z",
"version": "2.0.0"
}
]
}
"""
TestHelper.inject(into: NSData.self, value: test.data(using: .utf8)! as NSData)
bundle.version = "1.0.1"
// Act
let response = sut.updateStatus(for: "test")
// Assert
XCTAssertEqual(AppUpdateManager.Status.required, response)
}
func testUpdateStatus_WithNoIncrAndOlderThan7Days_ShouldReturnRequired() throws {
// Arrange
let test = """
{
"resultCount": 1,
"results": [
{
"currentVersionReleaseDate": "2019-12-31T09:41:00Z",
"version": "1.0.1"
}
]
}
"""
TestHelper.inject(into: NSData.self, value: test.data(using: .utf8)! as NSData)
bundle.version = "1.0.1"
// Act
let response = sut.updateStatus(for: "test")
// Assert
XCTAssertEqual(AppUpdateManager.Status.noUpdate, response)
}
// MARK: - Private properties
private var bundle: MockBundle!
private var sut: AppUpdateManager!
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment