Skip to content

Instantly share code, notes, and snippets.

@karthikAdaptavant
Last active July 30, 2018 13:32
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save karthikAdaptavant/30addecd53885435a9052db8f0afc0cb to your computer and use it in GitHub Desktop.
Save karthikAdaptavant/30addecd53885435a9052db8f0afc0cb to your computer and use it in GitHub Desktop.
App Update Manager
//
// 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()
}
}
@karthikAdaptavant
Copy link
Author

karthikAdaptavant commented Jul 27, 2018

Usage: Put this in your appdelegate

        AppUpdateManager.shared.versionCheckType = .daily
        AppUpdateManager.shared.alertType = .force
        AppUpdateManager.shared.checkForUpdate() 

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment