|
@@ -1,26 +1,42 @@
|
|
|
import UIKit
|
|
import UIKit
|
|
|
|
|
|
|
|
|
|
+/// AppVersionChecker is a singleton responsible for checking the app's version status.
|
|
|
|
|
+/// It fetches version data from remote sources (GitHub), caches the results, and notifies the user
|
|
|
|
|
+/// if an update is available or if the current version is blacklisted.
|
|
|
final class AppVersionChecker {
|
|
final class AppVersionChecker {
|
|
|
|
|
+ /// Shared singleton instance.
|
|
|
static let shared = AppVersionChecker()
|
|
static let shared = AppVersionChecker()
|
|
|
|
|
+
|
|
|
|
|
+ /// Private initializer to enforce the singleton pattern.
|
|
|
private init() {}
|
|
private init() {}
|
|
|
|
|
|
|
|
// MARK: - Persisted Properties
|
|
// MARK: - Persisted Properties
|
|
|
|
|
|
|
|
|
|
+ /// Cached app version for which data was last fetched.
|
|
|
@Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
|
|
@Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
|
|
|
|
|
+ /// The latest version fetched from GitHub.
|
|
|
@Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
|
|
@Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
|
|
|
|
|
+ /// The date when the latest version was checked.
|
|
|
@Persisted(key: "latestVersionChecked") private var latestVersionChecked: Date? = .distantPast
|
|
@Persisted(key: "latestVersionChecked") private var latestVersionChecked: Date? = .distantPast
|
|
|
|
|
+ /// Boolean flag indicating whether the current version is blacklisted.
|
|
|
@Persisted(key: "currentVersionBlackListed") private var currentVersionBlackListed: Bool = false
|
|
@Persisted(key: "currentVersionBlackListed") private var currentVersionBlackListed: Bool = false
|
|
|
|
|
+ /// Timestamp for the last time a blacklist notification was shown.
|
|
|
@Persisted(key: "lastBlacklistNotificationShown") private var lastBlacklistNotificationShown: Date? = .distantPast
|
|
@Persisted(key: "lastBlacklistNotificationShown") private var lastBlacklistNotificationShown: Date? = .distantPast
|
|
|
|
|
+ /// Timestamp for the last time a version update notification was shown.
|
|
|
@Persisted(key: "lastVersionUpdateNotificationShown") private var lastVersionUpdateNotificationShown: Date? = .distantPast
|
|
@Persisted(key: "lastVersionUpdateNotificationShown") private var lastVersionUpdateNotificationShown: Date? = .distantPast
|
|
|
|
|
+ /// Timestamp for the last time an expiration notification was shown.
|
|
|
@Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
|
|
@Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
|
|
|
|
|
|
|
|
// MARK: - Nested Types
|
|
// MARK: - Nested Types
|
|
|
|
|
|
|
|
- /// Types of data we fetch from GitHub for version checking.
|
|
|
|
|
|
|
+ /// GitHubDataType defines the type of data to fetch from GitHub for version checking.
|
|
|
private enum GitHubDataType {
|
|
private enum GitHubDataType {
|
|
|
|
|
+ /// The configuration file containing version information.
|
|
|
case versionConfig
|
|
case versionConfig
|
|
|
|
|
+ /// The JSON file listing blacklisted versions.
|
|
|
case blacklistedVersions
|
|
case blacklistedVersions
|
|
|
|
|
|
|
|
|
|
+ /// Returns the URL string associated with the data type.
|
|
|
var url: String {
|
|
var url: String {
|
|
|
switch self {
|
|
switch self {
|
|
|
case .versionConfig:
|
|
case .versionConfig:
|
|
@@ -31,24 +47,35 @@ final class AppVersionChecker {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Structures for decoding GitHub JSON data.
|
|
|
|
|
|
|
+ /// Model for decoding the blacklist JSON from GitHub.
|
|
|
private struct Blacklist: Decodable {
|
|
private struct Blacklist: Decodable {
|
|
|
|
|
+ /// Array of blacklisted version entries.
|
|
|
let blacklistedVersions: [VersionEntry]
|
|
let blacklistedVersions: [VersionEntry]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Model representing a single version entry in the blacklist.
|
|
|
private struct VersionEntry: Decodable {
|
|
private struct VersionEntry: Decodable {
|
|
|
|
|
+ /// The version string that is blacklisted.
|
|
|
let version: String
|
|
let version: String
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - Public Methods
|
|
// MARK: - Public Methods
|
|
|
|
|
|
|
|
- /// Checks for new or blacklisted versions and presents an alert if needed.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Checks for a new or blacklisted version and presents an alert if necessary.
|
|
|
|
|
+
|
|
|
|
|
+ This method determines whether there is an update or if the current version is blacklisted.
|
|
|
|
|
+ Depending on the result, it displays an alert on the given view controller, ensuring that alerts
|
|
|
|
|
+ are not shown too frequently (24 hours for blacklist and 2 weeks for update notifications).
|
|
|
|
|
+
|
|
|
|
|
+ - Parameter viewController: The UIViewController on which to present any alerts.
|
|
|
|
|
+ */
|
|
|
func checkAndNotifyVersionStatus(in viewController: UIViewController) {
|
|
func checkAndNotifyVersionStatus(in viewController: UIViewController) {
|
|
|
checkForNewVersion { [weak viewController] latestVersion, isNewer, isBlacklisted in
|
|
checkForNewVersion { [weak viewController] latestVersion, isNewer, isBlacklisted in
|
|
|
guard let vc = viewController else { return }
|
|
guard let vc = viewController else { return }
|
|
|
let now = Date()
|
|
let now = Date()
|
|
|
|
|
|
|
|
- // Check for critical (blacklisted) version.
|
|
|
|
|
|
|
+ // If the current version is blacklisted, show a critical update alert if not shown in the last 24 hours.
|
|
|
if isBlacklisted {
|
|
if isBlacklisted {
|
|
|
let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
|
|
let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
|
|
|
if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
|
|
if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
|
|
@@ -61,7 +88,7 @@ final class AppVersionChecker {
|
|
|
self.lastVersionUpdateNotificationShown = now
|
|
self.lastVersionUpdateNotificationShown = now
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
- // Check for a new version available.
|
|
|
|
|
|
|
+ // Otherwise, if a newer version is available, show an update alert if not shown in the last 2 weeks.
|
|
|
else if isNewer {
|
|
else if isNewer {
|
|
|
let lastShown = self.lastVersionUpdateNotificationShown ?? .distantPast
|
|
let lastShown = self.lastVersionUpdateNotificationShown ?? .distantPast
|
|
|
if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
|
|
if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
|
|
@@ -77,11 +104,24 @@ final class AppVersionChecker {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Refreshes the version information and returns the current state.
|
|
|
|
|
+
|
|
|
|
|
+ This method triggers a version check (using cached values if valid or fetching fresh data)
|
|
|
|
|
+ and then returns the current app version along with the latest version info, a flag indicating
|
|
|
|
|
+ whether the latest version is newer, and a flag indicating if the current version is blacklisted.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameter completion: A closure that receives the following parameters:
|
|
|
|
|
+ - currentVersion: The current app version.
|
|
|
|
|
+ - latestVersion: The latest version fetched from GitHub (if available).
|
|
|
|
|
+ - isNewer: `true` if the fetched version is newer than the current version.
|
|
|
|
|
+ - isBlacklisted: `true` if the current version is blacklisted.
|
|
|
|
|
+ */
|
|
|
func refreshVersionInfo(completion: @escaping (
|
|
func refreshVersionInfo(completion: @escaping (
|
|
|
- String /* currentVersion */,
|
|
|
|
|
- String? /* latestVersion */,
|
|
|
|
|
- Bool /* isNewer */,
|
|
|
|
|
- Bool /* isBlacklisted */
|
|
|
|
|
|
|
+ String, /* currentVersion */
|
|
|
|
|
+ String?, /* latestVersion */
|
|
|
|
|
+ Bool, /* isNewer */
|
|
|
|
|
+ Bool /* isBlacklisted */
|
|
|
) -> Void) {
|
|
) -> Void) {
|
|
|
let currentVersion = version()
|
|
let currentVersion = version()
|
|
|
checkForNewVersion { latestVersion, isNewer, isBlacklisted in
|
|
checkForNewVersion { latestVersion, isNewer, isBlacklisted in
|
|
@@ -91,7 +131,18 @@ final class AppVersionChecker {
|
|
|
|
|
|
|
|
// MARK: - Core Version Checking Logic
|
|
// MARK: - Core Version Checking Logic
|
|
|
|
|
|
|
|
- /// Checks if there is a new or blacklisted version.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Checks whether there is a new or blacklisted version.
|
|
|
|
|
+
|
|
|
|
|
+ This method attempts to use cached version data if it is less than 24 hours old and
|
|
|
|
|
+ corresponds to the current app version. If the cache is invalid or outdated,
|
|
|
|
|
+ it fetches fresh data from GitHub.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameter completion: A closure that receives:
|
|
|
|
|
+ - latestVersion: The latest version string (if available).
|
|
|
|
|
+ - isNewer: `true` if the fetched version is newer than the current version.
|
|
|
|
|
+ - isBlacklisted: `true` if the current version is blacklisted.
|
|
|
|
|
+ */
|
|
|
private func checkForNewVersion(completion: @escaping (String?, Bool, Bool) -> Void) {
|
|
private func checkForNewVersion(completion: @escaping (String?, Bool, Bool) -> Void) {
|
|
|
let currentVersion = version()
|
|
let currentVersion = version()
|
|
|
let now = Date()
|
|
let now = Date()
|
|
@@ -102,13 +153,13 @@ final class AppVersionChecker {
|
|
|
let persistedLatest = persistedLatestVersion
|
|
let persistedLatest = persistedLatestVersion
|
|
|
let isBlacklistedCached = currentVersionBlackListed
|
|
let isBlacklistedCached = currentVersionBlackListed
|
|
|
|
|
|
|
|
- // Reset notifications if the current app version differs from the cached one.
|
|
|
|
|
|
|
+ // If the current app version has changed, reset notification timestamps.
|
|
|
if let cachedVersion = cachedVersion, cachedVersion != currentVersion {
|
|
if let cachedVersion = cachedVersion, cachedVersion != currentVersion {
|
|
|
lastBlacklistNotificationShown = .distantPast
|
|
lastBlacklistNotificationShown = .distantPast
|
|
|
lastVersionUpdateNotificationShown = .distantPast
|
|
lastVersionUpdateNotificationShown = .distantPast
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // If cache is valid (<24 hours old) and for the current version, use it.
|
|
|
|
|
|
|
+ // Use cached data if it is valid (less than 24 hours old) and matches the current version.
|
|
|
if let cachedVersion = cachedVersion,
|
|
if let cachedVersion = cachedVersion,
|
|
|
cachedVersion == currentVersion,
|
|
cachedVersion == currentVersion,
|
|
|
now.timeIntervalSince(lastChecked) < 24 * 3600,
|
|
now.timeIntervalSince(lastChecked) < 24 * 3600,
|
|
@@ -119,26 +170,39 @@ final class AppVersionChecker {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Otherwise, fetch fresh data and update the cache.
|
|
|
|
|
|
|
+ // Otherwise, fetch fresh data from GitHub and update the cache.
|
|
|
fetchDataAndUpdateCache(currentVersion: currentVersion, completion: completion)
|
|
fetchDataAndUpdateCache(currentVersion: currentVersion, completion: completion)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Fetches version and blacklist data from GitHub, updates persisted values, and then calls completion.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Fetches version and blacklist data from GitHub, updates persisted values, and invokes the completion handler.
|
|
|
|
|
+
|
|
|
|
|
+ This method performs two sequential network requests: first for the version configuration and then for the
|
|
|
|
|
+ blacklisted versions. After parsing the fetched data and comparing version values, it updates the cache and calls
|
|
|
|
|
+ the completion handler with the results.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameters:
|
|
|
|
|
+ - currentVersion: The current app version.
|
|
|
|
|
+ - completion: A closure that receives:
|
|
|
|
|
+ - latestVersion: The latest version string from GitHub (if available).
|
|
|
|
|
+ - isNewer: `true` if the fetched version is newer than the current version.
|
|
|
|
|
+ - isBlacklisted: `true` if the current version is blacklisted.
|
|
|
|
|
+ */
|
|
|
private func fetchDataAndUpdateCache(currentVersion: String, completion: @escaping (String?, Bool, Bool) -> Void) {
|
|
private func fetchDataAndUpdateCache(currentVersion: String, completion: @escaping (String?, Bool, Bool) -> Void) {
|
|
|
fetchData(for: .versionConfig) { versionData in
|
|
fetchData(for: .versionConfig) { versionData in
|
|
|
self.fetchData(for: .blacklistedVersions) { blacklistData in
|
|
self.fetchData(for: .blacklistedVersions) { blacklistData in
|
|
|
DispatchQueue.main.async {
|
|
DispatchQueue.main.async {
|
|
|
- // Parse the version from the fetched config.
|
|
|
|
|
|
|
+ // Parse the version from the fetched configuration data.
|
|
|
let fetchedVersion = versionData
|
|
let fetchedVersion = versionData
|
|
|
.flatMap { String(data: $0, encoding: .utf8) }
|
|
.flatMap { String(data: $0, encoding: .utf8) }
|
|
|
.flatMap { self.parseVersionFromConfig(contents: $0) }
|
|
.flatMap { self.parseVersionFromConfig(contents: $0) }
|
|
|
|
|
|
|
|
- // Compare versions.
|
|
|
|
|
|
|
+ // Determine if the fetched version is newer than the current version.
|
|
|
let isNewer = fetchedVersion.map {
|
|
let isNewer = fetchedVersion.map {
|
|
|
self.isVersion($0, newerThan: currentVersion)
|
|
self.isVersion($0, newerThan: currentVersion)
|
|
|
} ?? false
|
|
} ?? false
|
|
|
|
|
|
|
|
- // Parse and determine if current version is blacklisted.
|
|
|
|
|
|
|
+ // Determine if the current version is blacklisted.
|
|
|
let isBlacklisted = (try? blacklistData.flatMap {
|
|
let isBlacklisted = (try? blacklistData.flatMap {
|
|
|
try JSONDecoder().decode(Blacklist.self, from: $0)
|
|
try JSONDecoder().decode(Blacklist.self, from: $0)
|
|
|
})?.blacklistedVersions
|
|
})?.blacklistedVersions
|
|
@@ -159,7 +223,16 @@ final class AppVersionChecker {
|
|
|
|
|
|
|
|
// MARK: - Data Fetching Helper
|
|
// MARK: - Data Fetching Helper
|
|
|
|
|
|
|
|
- /// Fetches data from GitHub for a given data type.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Fetches data from GitHub for a specified data type.
|
|
|
|
|
+
|
|
|
|
|
+ This helper method builds a URL from the provided GitHubDataType and executes a network request.
|
|
|
|
|
+ If the request is successful and returns valid data (HTTP status 200), the data is passed to the completion handler.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameters:
|
|
|
|
|
+ - dataType: The type of GitHub data to fetch (version configuration or blacklisted versions).
|
|
|
|
|
+ - completion: A closure that receives the fetched data as an optional `Data` object.
|
|
|
|
|
+ */
|
|
|
private func fetchData(for dataType: GitHubDataType, completion: @escaping (Data?) -> Void) {
|
|
private func fetchData(for dataType: GitHubDataType, completion: @escaping (Data?) -> Void) {
|
|
|
guard let url = URL(string: dataType.url) else {
|
|
guard let url = URL(string: dataType.url) else {
|
|
|
completion(nil)
|
|
completion(nil)
|
|
@@ -180,7 +253,15 @@ final class AppVersionChecker {
|
|
|
|
|
|
|
|
// MARK: - Helpers
|
|
// MARK: - Helpers
|
|
|
|
|
|
|
|
- /// Parses the version string from a configuration file's content.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Parses the version string from the contents of a configuration file.
|
|
|
|
|
+
|
|
|
|
|
+ The method scans each line of the provided content for an occurrence of "APP_VERSION" and then
|
|
|
|
|
+ extracts the version number following the "=" delimiter.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameter contents: A string containing the contents of the configuration file.
|
|
|
|
|
+ - Returns: The extracted version string if found; otherwise, `nil`.
|
|
|
|
|
+ */
|
|
|
private func parseVersionFromConfig(contents: String) -> String? {
|
|
private func parseVersionFromConfig(contents: String) -> String? {
|
|
|
let lines = contents.split(separator: "\n")
|
|
let lines = contents.split(separator: "\n")
|
|
|
for line in lines {
|
|
for line in lines {
|
|
@@ -196,7 +277,18 @@ final class AppVersionChecker {
|
|
|
return nil
|
|
return nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Compares two version strings to determine if the fetched version is newer.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Compares two version strings to determine if the fetched version is newer than the current version.
|
|
|
|
|
+
|
|
|
|
|
+ The version strings are split into numeric components and compared sequentially.
|
|
|
|
|
+ If any component of the fetched version is greater than its counterpart in the current version,
|
|
|
|
|
+ the function returns `true`; if lower, it returns `false`.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameters:
|
|
|
|
|
+ - fetchedVersion: The version string obtained from GitHub.
|
|
|
|
|
+ - currentVersion: The current app version.
|
|
|
|
|
+ - Returns: `true` if the fetched version is newer than the current version; otherwise, `false`.
|
|
|
|
|
+ */
|
|
|
private func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool {
|
|
private func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool {
|
|
|
let fetchedComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 }
|
|
let fetchedComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 }
|
|
|
let currentComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 }
|
|
let currentComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 }
|
|
@@ -214,12 +306,26 @@ final class AppVersionChecker {
|
|
|
return false
|
|
return false
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Returns the current app version.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Retrieves the current app version from the main bundle.
|
|
|
|
|
+
|
|
|
|
|
+ - Returns: The current app version as defined in the app's Info.plist under "CFBundleShortVersionString",
|
|
|
|
|
+ or `"Unknown"` if not available.
|
|
|
|
|
+ */
|
|
|
private func version() -> String {
|
|
private func version() -> String {
|
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
|
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /// Presents an alert on the provided view controller.
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ Presents an alert on the specified view controller with a given title and message.
|
|
|
|
|
+
|
|
|
|
|
+ The alert is dispatched to the main thread to ensure UI updates occur correctly.
|
|
|
|
|
+
|
|
|
|
|
+ - Parameters:
|
|
|
|
|
+ - viewController: The UIViewController on which the alert should be presented.
|
|
|
|
|
+ - title: The title text for the alert.
|
|
|
|
|
+ - message: The body message of the alert.
|
|
|
|
|
+ */
|
|
|
private func showAlert(on viewController: UIViewController, title: String, message: String) {
|
|
private func showAlert(on viewController: UIViewController, title: String, message: String) {
|
|
|
DispatchQueue.main.async {
|
|
DispatchQueue.main.async {
|
|
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|
|
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
|