Last active
July 30, 2018 13:32
-
-
Save karthiikmk/30addecd53885435a9052db8f0afc0cb to your computer and use it in GitHub Desktop.
App Update Manager
This file contains 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
// | |
// AppstoreUpdateManager.swift | |
// AppStoreUpdateManager | |
// | |
// Created by Karthik on 7/27/18. | |
// Copyright © 2018 Karthik. All rights reserved. | |
// | |
import Foundation | |
import UIKit | |
//MARK: Protocols | |
protocol AppStoreUpdateManagerDelegate: class { | |
func didTapLaunchAppStore() | |
func didTapCancelAlert() | |
func foundNewUpdate(_ update: AppUpdateModel.Update) | |
func didShowAlert() | |
func didFailed(withError error: AppUpdateManagerError) | |
} | |
public enum AppUpdateAlertType { | |
case force // force update (no option will be give) | |
case option // update or cancel | |
case none // dont show alert (used to show custom alert) | |
} | |
public enum AppUpdateVersionCheckType: Int { | |
case immediately = 0 | |
case daily = 1 | |
case weekly = 7 | |
} | |
enum AppUpdateManagerError: Error { | |
case invalidiTunesUrl | |
case appStoreRequestFailed(error: Error?) | |
case appStoreReturnedInvalidData | |
case jsonParsingFailure | |
case appStoreReturnsEmptyData | |
case appStoreOSVersionNumberFailure | |
case appStoreOSVersionUnsupported | |
case appStoreAppIDFailure | |
case noUpdateAvailable | |
case checkDoneRecently(date: Date) | |
case invalidAppUpdateManageModel | |
var localizedDescription: String { | |
switch self { | |
case .invalidiTunesUrl: | |
return "The iTunes URL is invalid." | |
case .appStoreRequestFailed(let error): | |
return "Error retrieving App Store data as an error was returned\nAlso, the following system level error was returned: \(String(describing: error))" | |
case .appStoreReturnedInvalidData: | |
return "Error retrieving App Store data as Invalid data or nil data." | |
case .jsonParsingFailure: | |
return "Error while parsing json)" | |
case .appStoreReturnsEmptyData: | |
return "Error retrieving App Store data as the JSON results were empty. Is your app available in the US? If not, change the `countryCode` variable to fix this error." | |
case .appStoreOSVersionNumberFailure: | |
return "Error retrieving iOS version number as there was no data returned." | |
case .appStoreOSVersionUnsupported: | |
return "The version of iOS on the device is lower than that of the one required by the app verison update." | |
case .appStoreAppIDFailure: | |
return "Error retrieving trackId as the JSON does not contain a 'trackId' key." | |
case .noUpdateAvailable: | |
return "No new update available." | |
case .checkDoneRecently(let date): | |
return "Version check done recently: \(date)" | |
case .invalidAppUpdateManageModel: | |
return "Invalid App Manage Model" | |
} | |
} | |
} | |
//MARK: AppUpdateManager | |
class AppUpdateManager { | |
public static let shared = AppUpdateManager() | |
// global variables | |
public var defaults: UserDefaults = UserDefaults.standard | |
public weak var delegate: AppStoreUpdateManagerDelegate? | |
public var bundleId: String? | |
public var versionCheckType: AppUpdateVersionCheckType = .immediately | |
public var majorAlertType: AppUpdateAlertType = .force | |
public var patchAlertType: AppUpdateAlertType = .option | |
public lazy var isDebugMode: Bool = true | |
// local variables | |
internal var updaterWindow: UIWindow? | |
fileprivate let lastVersionCheck = "LastVersionChecked" | |
fileprivate var currentInstalledVersion: String? = Bundle.versionNumber | |
fileprivate var updateModel: AppUpdateModel.Update? = nil | |
fileprivate lazy var alertViewIsVisible: Bool = false | |
fileprivate var lastVersionCheckedDate: Date? | |
fileprivate var currentAppStoreVersion: String? | |
fileprivate var alertMessage = AppUpdateAlertMessage() | |
// send Prefrence for testing. | |
public init(prefrence: UserDefaults = UserDefaults.standard) { | |
self.defaults = prefrence | |
self.lastVersionCheckedDate = defaults.object(forKey: lastVersionCheck) as? Date | |
} | |
// Check AppStore version | |
public func checkForUpdate() { | |
self.versionCheckType == .immediately ? self.perfromVersionCheck() : self.checkAndPerformVersionCheck() | |
} | |
fileprivate func checkAndPerformVersionCheck() { | |
guard let lastCheckDate = lastVersionCheckedDate else { | |
self.perfromVersionCheck() | |
return | |
} | |
printMessage("LastCheckedDate Mills: \(lastCheckDate.toMills)") | |
// guard Date.days(since: lastCheckDate) >= self.versionCheckType.rawValue else { | |
// postError(.checkDoneRecently(date: lastCheckDate)) | |
// return | |
// } | |
// | |
self.perfromVersionCheck() | |
} | |
fileprivate func printMessage(_ message: String) { | |
guard isDebugMode else { return} | |
print("[AppUpdateManager] \(message)") | |
} | |
fileprivate func postError(_ error: AppUpdateManagerError) { | |
self.delegate?.didFailed(withError: error) | |
guard isDebugMode else { return} | |
print("[AppUpdateManager] \(error.localizedDescription)") | |
} | |
} | |
//MARK: Api Calls | |
extension AppUpdateManager { | |
fileprivate func iTunesURLFromString() throws -> URL { | |
var components = URLComponents() | |
components.scheme = "https" | |
components.host = "itunes.apple.com" | |
components.path = "/lookup" | |
components.queryItems = [URLQueryItem(name: "bundleId", value: self.bundleId ?? Bundle.bundleId)] | |
guard let url = components.url, !url.absoluteString.isEmpty else { | |
throw AppUpdateManagerError.invalidiTunesUrl | |
} | |
return url | |
} | |
fileprivate func perfromVersionCheck() { | |
do { | |
let url = try iTunesURLFromString() | |
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringCacheData, timeoutInterval: 45) | |
URLSession.shared.dataTask(with: request, completionHandler: { [weak self] (data, response, error) in | |
self?.processResults(withData: data, error: error) | |
}).resume() | |
} catch _ { | |
postError(.invalidiTunesUrl) | |
} | |
} | |
fileprivate func processResults(withData responseData: Data?, error: Error?) { | |
guard error == nil else { | |
postError(.appStoreRequestFailed(error: error)) | |
return | |
} | |
guard let data = responseData else { | |
postError(.appStoreReturnedInvalidData) | |
return | |
} | |
self.updateVersionCheckedDate() | |
do { | |
let decodedData = try JSONDecoder().decode(AppUpdateModel.self, from: data) | |
guard !decodedData.results.isEmpty else { | |
postError(.appStoreReturnsEmptyData) | |
return | |
} | |
DispatchQueue.main.async {[weak self] in | |
self?.processVersionCheck(with: decodedData) | |
} | |
} catch _ { | |
postError(.jsonParsingFailure) | |
} | |
} | |
fileprivate func processVersionCheck(with model: AppUpdateModel) { | |
guard isUpdateCompatibleWithDeviceOS(for: model) else { | |
return | |
} | |
guard let result = model.results.first else { | |
postError(.invalidAppUpdateManageModel) | |
return | |
} | |
self.updateModel = result | |
self.currentAppStoreVersion = result.version | |
guard isAppStoreVersionNewer() else { | |
postError(.noUpdateAvailable) | |
return | |
} | |
let type = getAlertType(installedVersion: currentInstalledVersion, appStoreVersion: currentAppStoreVersion) | |
guard canShowAlert(forType: type) else { | |
self.delegate?.foundNewUpdate(result) | |
return | |
} | |
self.showAlert(type: type) | |
} | |
fileprivate func canShowAlert(forType type: AppUpdateAlertType) -> Bool { | |
return (type != .none) | |
} | |
fileprivate func showAlert(type: AppUpdateAlertType) { | |
let newVersionMessage = getConstructedMessage(forDesc: self.alertMessage.desc) | |
let alertController = UIAlertController(title: alertMessage.title, message: newVersionMessage, preferredStyle: .alert) | |
if type == .option { alertController.addAction(nextTimeAlertAction()) } | |
alertController.addAction(updateAlertAction()) | |
guard !alertViewIsVisible else { return } | |
alertController.show() | |
alertViewIsVisible = true | |
delegate?.didShowAlert() | |
} | |
} | |
// MARK: Validate Utils | |
extension AppUpdateManager { | |
fileprivate func updateVersionCheckedDate() { | |
lastVersionCheckedDate = Date() | |
guard let lastVersionCheckPerformedOnDate = lastVersionCheckedDate else { | |
return | |
} | |
self.defaults.set(lastVersionCheckPerformedOnDate, forKey: lastVersionCheck) | |
self.defaults.synchronize() | |
} | |
fileprivate func getConstructedMessage(forDesc desc: String) -> String { | |
guard let currentAppStoreVersion = currentAppStoreVersion else { | |
return String(format: desc, updateModel?.appName ?? Bundle.bestMatchingAppName(), "Unknown") | |
} | |
return String(format: desc, updateModel?.appName ?? Bundle.bestMatchingAppName(), currentAppStoreVersion) | |
} | |
fileprivate func isUpdateCompatibleWithDeviceOS(for model: AppUpdateModel) -> Bool { | |
guard let requiredOSVersion = model.results.first?.minimumOSVersion else { | |
postError(.appStoreOSVersionNumberFailure) | |
return false | |
} | |
let systemVersion = UIDevice.current.systemVersion | |
// Cheking Minimum OS versions | |
guard systemVersion.compare(requiredOSVersion, options: .numeric) == .orderedDescending || | |
systemVersion.compare(requiredOSVersion, options: .numeric) == .orderedSame else { | |
postError(.appStoreOSVersionUnsupported) | |
return false | |
} | |
return true | |
} | |
fileprivate func isAppStoreVersionNewer() -> Bool { | |
guard let currentInstalledVersion = currentInstalledVersion, let currentAppStoreVersion = currentAppStoreVersion, | |
(currentInstalledVersion.compare(currentAppStoreVersion, options: .numeric) == .orderedAscending) else { | |
return false | |
} | |
return true | |
} | |
fileprivate func getAlertType(installedVersion: String?, appStoreVersion: String?) -> AppUpdateAlertType { | |
guard let currentInstalledVersion = installedVersion, let currentAppStoreVersion = appStoreVersion else { | |
return .none | |
} | |
let oldVersion = (currentInstalledVersion).lazy.split {$0 == "."}.map { String($0) }.map {Int($0) ?? 0} | |
let newVersion = (currentAppStoreVersion).lazy.split {$0 == "."}.map { String($0) }.map {Int($0) ?? 0} | |
guard let newVersionFirst = newVersion.first, let oldVersionFirst = oldVersion.first else { | |
return .none | |
} | |
if newVersionFirst > oldVersionFirst { // A.b.c.d | |
return self.majorAlertType | |
} else if newVersion.count > 1 && (oldVersion.count <= 1 || newVersion[1] > oldVersion[1]) { // a.B.c.d | |
return self.patchAlertType | |
} else if newVersion.count > 2 && (oldVersion.count <= 2 || newVersion[2] > oldVersion[2]) { // a.b.C.d | |
return self.patchAlertType | |
} else if newVersion.count > 3 && (oldVersion.count <= 3 || newVersion[3] > oldVersion[3]) { // a.b.c.D | |
return self.patchAlertType | |
} | |
return .none | |
} | |
fileprivate func hideWindow() { | |
if let updaterWindow = updaterWindow { | |
updaterWindow.isHidden = true | |
self.updaterWindow = nil | |
} | |
} | |
fileprivate func launchAppStore() { | |
guard let model = self.updateModel, let url = URL(string: "https://itunes.apple.com/app/id\(model.appId)") else { | |
return | |
} | |
DispatchQueue.main.async { | |
UIApplication.shared.open(url, options: [:], completionHandler: nil) | |
} | |
} | |
} | |
//MARK: Alert Actions | |
extension AppUpdateManager { | |
func updateAlertAction() -> UIAlertAction { | |
let action = UIAlertAction(title: alertMessage.buttonTitle, style: .default) { [weak self] _ in | |
guard let strongSelf = self else { return } | |
strongSelf.hideWindow() | |
strongSelf.launchAppStore() | |
strongSelf.delegate?.didTapLaunchAppStore() | |
strongSelf.alertViewIsVisible = false | |
return | |
} | |
return action | |
} | |
func nextTimeAlertAction() -> UIAlertAction { | |
let action = UIAlertAction(title: alertMessage.nextTimeButtonTitle, style: .default) { [weak self] _ in | |
guard let strongSelf = self else { return } | |
strongSelf.hideWindow() | |
strongSelf.delegate?.didTapCancelAlert() | |
strongSelf.alertViewIsVisible = false | |
return | |
} | |
return action | |
} | |
} | |
// MARK: Alert Message | |
struct AppUpdateModel: Decodable { | |
private enum CodingKeys: String, CodingKey { | |
case results | |
} | |
public let results: [Update] //Itunes returns array of json | |
public struct Update: Decodable { | |
private enum CodingKeys: String, CodingKey { | |
case appId = "trackId" | |
case appName = "trackName" | |
case currentVersionReleaseDate | |
case minimumOSVersion = "minimumOsVersion" | |
case releaseNotes | |
case version | |
} | |
public let appId: Int | |
public let appName: String | |
public let currentVersionReleaseDate: String | |
public let minimumOSVersion: String | |
public let releaseNotes: String? | |
public let version: String | |
} | |
} | |
public struct AppUpdateAlertMessage { | |
public struct Constants { | |
public static let nextTime = "Next time" | |
public static let updateMessage = "A new version of %@ is available. Please update to version %@ now." | |
public static let updateTitle = "Update Available" | |
public static let updateNow = "Update Now" | |
} | |
let nextTimeButtonTitle: String | |
let buttonTitle: String | |
let desc: String | |
let title: String | |
public init(title: String = Constants.updateTitle, | |
message: String = Constants.updateMessage, | |
buttonTitle: String = Constants.updateNow, | |
nextButtonTitle: String = Constants.nextTime) { | |
self.buttonTitle = buttonTitle | |
self.nextTimeButtonTitle = nextButtonTitle | |
self.desc = message | |
self.title = title | |
} | |
} | |
//MARK: Helpers | |
extension UIAlertController { | |
func show() { | |
let window = UIWindow(frame: UIScreen.main.bounds) | |
window.rootViewController = AppUpdateManageViewController() | |
window.windowLevel = UIWindowLevelAlert + 1 | |
AppUpdateManager.shared.updaterWindow = window | |
window.makeKeyAndVisible() | |
window.rootViewController?.present(self, animated: true, completion: nil) | |
} | |
} | |
extension Date { | |
static func days(since date: Date, to: Date = Date()) -> Int { | |
let calendar = Calendar.current | |
let components = calendar.dateComponents([.day], from: date, to: to) | |
return components.day ?? 0 | |
} | |
static func create(_ year: Int, _ month: Int, _ day: Int) -> Date { | |
let gregorianCalendar = Calendar(identifier: .gregorian) | |
let dateComponents = DateComponents(calendar: gregorianCalendar, year: year, month: month, day: day) | |
return gregorianCalendar.date(from: dateComponents)! | |
} | |
var toMills: Int64 { | |
return Int64(self.timeIntervalSince1970 * 1000) | |
} | |
init(millis: Int64) { | |
self = Date(timeIntervalSince1970: TimeInterval(millis / 1000)) | |
self.addTimeInterval(TimeInterval(Double(millis % 1000) / 1000 )) | |
} | |
} | |
extension Bundle { | |
final class var bundleId: String { | |
return Bundle.main.bundleIdentifier ?? "" | |
} | |
final class var versionNumber: String? { | |
return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String | |
} | |
final class func bestMatchingAppName() -> String { | |
let bundleDisplayName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String | |
let bundleName = Bundle.main.object(forInfoDictionaryKey: kCFBundleNameKey as String) as? String | |
return bundleDisplayName ?? bundleName ?? "" | |
} | |
} | |
final class AppUpdateManageViewController: UIViewController { | |
override var preferredStatusBarStyle: UIStatusBarStyle { return UIApplication.shared.statusBarStyle } | |
} | |
extension AppUpdateManager { | |
func testSetCurrentInstalledVersion(version: String) { | |
currentInstalledVersion = version | |
} | |
func testSetAppStoreVersion(version: String) { | |
currentAppStoreVersion = version | |
} | |
func testIsAppStoreVersionNewer() -> Bool { | |
return isAppStoreVersionNewer() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage: Put this in your appdelegate