Created
April 12, 2020 09:00
-
-
Save diamantidis/fa7a5d4dce336e5b8f83827cb229fa9c to your computer and use it in GitHub Desktop.
Notify users for app update on an iOS app
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import Foundation | |
| protocol BundleType { | |
| func object(forInfoDictionaryKey key: String) -> Any? | |
| } | |
| extension Bundle: BundleType {} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| ... |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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