AppVersionChecker.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496
  1. import UIKit
  2. // AppVersionChecker is a singleton responsible for checking the app's version status.
  3. // It fetches version data from remote sources (GitHub), caches the results, and notifies the user
  4. // if an update is available or if the current version is blacklisted.
  5. @MainActor final class AppVersionChecker {
  6. // Shared singleton instance.
  7. static let shared = AppVersionChecker()
  8. // Private initializer to enforce the singleton pattern.
  9. private init() {}
  10. // MARK: - Persisted Properties
  11. // Cached app version for which data was last fetched.
  12. @Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
  13. // The latest version fetched from GitHub.
  14. @Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
  15. // The date when the latest version was checked.
  16. @Persisted(key: "latestVersionChecked") private var latestVersionChecked: Date? = .distantPast
  17. // Boolean flag indicating whether the current version is blacklisted.
  18. @Persisted(key: "currentVersionBlackListed") private var currentVersionBlackListed: Bool = false
  19. // Timestamp for the last time a blacklist notification was shown.
  20. @Persisted(key: "lastBlacklistNotificationShown") private var lastBlacklistNotificationShown: Date? = .distantPast
  21. // Timestamp for the last time a version update notification was shown.
  22. @Persisted(key: "lastVersionUpdateNotificationShown") private var lastVersionUpdateNotificationShown: Date? = .distantPast
  23. // Timestamp for the last time an expiration notification was shown.
  24. @Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
  25. // Dev version properties
  26. // Cached app version for which dev data was last fetched.
  27. @Persisted(key: "cachedForDevVersion") private var cachedForDevVersion: String? = nil
  28. // The latest dev version fetched from GitHub.
  29. @Persisted(key: "latestDevVersion") private var persistedLatestDevVersion: String? = nil
  30. // The date when the latest dev version was checked.
  31. @Persisted(key: "latestDevVersionChecked") private var latestDevVersionChecked: Date? = .distantPast
  32. // MARK: - Nested Types
  33. // GitHubDataType defines the type of data to fetch from GitHub for version checking.
  34. private enum GitHubDataType {
  35. // The configuration file containing version information.
  36. case versionConfig
  37. // The configuration file containing dev version information.
  38. case devVersionConfig
  39. // The JSON file listing blacklisted versions.
  40. case blacklistedVersions
  41. // Returns the URL string associated with the data type.
  42. var url: String {
  43. switch self {
  44. case .versionConfig:
  45. return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/Config.xcconfig"
  46. case .devVersionConfig:
  47. return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/dev/Config.xcconfig"
  48. case .blacklistedVersions:
  49. return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/blacklisted-versions.json"
  50. }
  51. }
  52. }
  53. // Model for decoding the blacklist JSON from GitHub.
  54. private struct Blacklist: Decodable {
  55. // Array of blacklisted version entries.
  56. let blacklistedVersions: [VersionEntry]
  57. }
  58. // Model representing a single version entry in the blacklist.
  59. private struct VersionEntry: Decodable {
  60. // The version string that is blacklisted.
  61. let version: String
  62. }
  63. // MARK: - Public Methods
  64. // Checks for a new or blacklisted version and presents an alert if necessary.
  65. //
  66. // This method determines whether there is an update or if the current version is blacklisted.
  67. // Depending on the result, it displays an alert on the given view controller, ensuring that alerts
  68. // are not shown too frequently (24 hours for blacklist and 2 weeks for update notifications).
  69. //
  70. // - Parameter viewController: The UIViewController on which to present any alerts.
  71. func checkAndNotifyVersionStatus(in viewController: UIViewController) {
  72. Task {
  73. let (latestVersion, isNewer, isBlacklisted) = await checkForNewVersion()
  74. let now = Date()
  75. // If the current version is blacklisted, show a critical update alert if not shown in the last 24 hours.
  76. if isBlacklisted {
  77. let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
  78. if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
  79. self.showAlert(
  80. on: viewController,
  81. title: String(localized: "Update Required", comment: "Title for critical update alert"),
  82. message: String(
  83. localized: "The current version has a critical issue and should be updated as soon as possible.",
  84. comment: "Message for critical update alert"
  85. )
  86. )
  87. self.lastBlacklistNotificationShown = now
  88. self.lastVersionUpdateNotificationShown = now
  89. }
  90. }
  91. // Otherwise, if a newer version is available, show an update alert if not shown in the last 2 weeks.
  92. else if isNewer {
  93. let lastShown = self.lastVersionUpdateNotificationShown ?? .distantPast
  94. if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
  95. let versionText = latestVersion ?? String(localized: "Unknown", comment: "Fallback text for unknown version")
  96. self.showAlert(
  97. on: viewController,
  98. title: String(localized: "Update Available", comment: "Title for update available alert"),
  99. message: String(
  100. localized: "A new version (\(versionText)) is available. It is recommended to update.",
  101. comment: "Message for update available alert"
  102. )
  103. )
  104. self.lastVersionUpdateNotificationShown = now
  105. }
  106. }
  107. }
  108. }
  109. // Refreshes the version information and returns the current state (completion handler version).
  110. //
  111. // This method triggers a version check (using cached values if valid or fetching fresh data)
  112. // and then returns the current app version along with the latest version info, a flag indicating
  113. // whether the latest version is newer, and a flag indicating if the current version is blacklisted.
  114. //
  115. // - Parameter completion: A closure that receives the following parameters:
  116. // - currentVersion: The current app version.
  117. // - latestVersion: The latest version fetched from GitHub (if available).
  118. // - isNewer: `true` if the fetched version is newer than the current version.
  119. // - isBlacklisted: `true` if the current version is blacklisted.
  120. func refreshVersionInfo(completion: @escaping (
  121. String,
  122. String?,
  123. Bool,
  124. Bool
  125. ) -> Void) {
  126. Task {
  127. let result = await refreshVersionInfo()
  128. completion(result.currentVersion, result.latestVersion, result.isNewer, result.isBlacklisted)
  129. }
  130. }
  131. // Refreshes the version information and returns the current state (async version).
  132. //
  133. // This method triggers a version check (using cached values if valid or fetching fresh data)
  134. // and then returns the current app version along with the latest version info, a flag indicating
  135. // whether the latest version is newer, and a flag indicating if the current version is blacklisted.
  136. //
  137. // - Returns: A tuple containing:
  138. // - currentVersion: The current app version.
  139. // - latestVersion: The latest version fetched from GitHub (if available).
  140. // - isNewer: `true` if the fetched version is newer than the current version.
  141. // - isBlacklisted: `true` if the current version is blacklisted.
  142. func refreshVersionInfo() async -> (currentVersion: String, latestVersion: String?, isNewer: Bool, isBlacklisted: Bool) {
  143. let currentVersion = version()
  144. let (latestVersion, isNewer, isBlacklisted) = await checkForNewVersion()
  145. return (currentVersion, latestVersion, isNewer, isBlacklisted)
  146. }
  147. // Checks for the latest dev version with caching and comparison (completion handler version).
  148. //
  149. // This method attempts to use cached dev version data if it is less than 24 hours old and
  150. // corresponds to the current app version. If the cache is invalid or outdated,
  151. // it fetches fresh data from GitHub.
  152. //
  153. // - Parameter completion: A closure that receives:
  154. // - latestDevVersion: The latest dev version string (if available).
  155. // - isNewer: `true` if the fetched dev version is newer than the current version.
  156. func checkForNewDevVersion(completion: @escaping (String?, Bool) -> Void) {
  157. Task {
  158. let result = await checkForNewDevVersion()
  159. completion(result.0, result.1)
  160. }
  161. }
  162. // Checks for the latest dev version with caching and comparison (async version).
  163. //
  164. // This method attempts to use cached dev version data if it is less than 24 hours old and
  165. // corresponds to the current app version. If the cache is invalid or outdated,
  166. // it fetches fresh data from GitHub.
  167. //
  168. // - Returns: A tuple containing:
  169. // - latestDevVersion: The latest dev version string (if available).
  170. // - isNewer: `true` if the fetched dev version is newer than the current version.
  171. func checkForNewDevVersion() async -> (String?, Bool) {
  172. // For dev version, we need to compare against the current dev version, not the main version
  173. let currentDevVersion = Bundle.main.object(forInfoDictionaryKey: "AppDevVersion") as? String ?? version()
  174. let now = Date()
  175. // Retrieve cached values
  176. let lastChecked = latestDevVersionChecked ?? .distantPast
  177. let cachedVersion = cachedForDevVersion
  178. let persistedLatestDev = persistedLatestDevVersion
  179. // Use cached data if it is valid (less than 24 hours old) and matches the current version
  180. if let cachedVersion = cachedVersion,
  181. cachedVersion == currentDevVersion,
  182. now.timeIntervalSince(lastChecked) < 24 * 3600,
  183. let persistedLatestDev = persistedLatestDev
  184. {
  185. let isNewer = isVersion(persistedLatestDev, newerThan: currentDevVersion)
  186. return (persistedLatestDev, isNewer)
  187. }
  188. // Otherwise, fetch fresh data from GitHub and update the cache
  189. return await fetchDevVersionAndUpdateCache(currentVersion: currentDevVersion)
  190. }
  191. // Fetches dev version data from GitHub, updates persisted values, and returns the result.
  192. //
  193. // - Parameters:
  194. // - currentVersion: The current app version.
  195. // - Returns: A tuple containing:
  196. // - latestDevVersion: The latest dev version string from GitHub (if available).
  197. // - isNewer: `true` if the fetched dev version is newer than the current version.
  198. private func fetchDevVersionAndUpdateCache(currentVersion: String) async -> (String?, Bool) {
  199. let versionData = await fetchData(for: .devVersionConfig)
  200. // Parse the dev version from the fetched configuration data
  201. let configContents = versionData.flatMap { String(data: $0, encoding: .utf8) }
  202. let fetchedDevVersion = configContents.flatMap { self.parseDevVersionFromConfig(contents: $0) }
  203. #if DEBUG
  204. print("AppVersionChecker.fetchDevVersion: Current dev version: \(currentVersion)")
  205. print("AppVersionChecker.fetchDevVersion: Fetched dev version: \(fetchedDevVersion ?? "nil")")
  206. if let contents = configContents {
  207. let lines = contents.split(separator: "\n")
  208. for line in lines where line.contains("VERSION") {
  209. print("AppVersionChecker.fetchDevVersion: Config line: \(line)")
  210. }
  211. }
  212. #endif
  213. // Determine if the fetched dev version is newer than the current version
  214. let isNewer = fetchedDevVersion.map {
  215. self.isVersion($0, newerThan: currentVersion)
  216. } ?? false
  217. // Update persisted cache
  218. persistedLatestDevVersion = fetchedDevVersion
  219. latestDevVersionChecked = Date()
  220. cachedForDevVersion = currentVersion
  221. return (fetchedDevVersion, isNewer)
  222. }
  223. // MARK: - Core Version Checking Logic
  224. // Checks whether there is a new or blacklisted version (completion handler version).
  225. //
  226. // This method attempts to use cached version data if it is less than 24 hours old and
  227. // corresponds to the current app version. If the cache is invalid or outdated,
  228. // it fetches fresh data from GitHub.
  229. //
  230. // - Parameter completion: A closure that receives:
  231. // - latestVersion: The latest version string (if available).
  232. // - isNewer: `true` if the fetched version is newer than the current version.
  233. // - isBlacklisted: `true` if the current version is blacklisted.
  234. private func checkForNewVersion(completion: @escaping (String?, Bool, Bool) -> Void) {
  235. Task {
  236. let result = await checkForNewVersion()
  237. completion(result.0, result.1, result.2)
  238. }
  239. }
  240. // Checks whether there is a new or blacklisted version (async version).
  241. //
  242. // This method attempts to use cached version data if it is less than 24 hours old and
  243. // corresponds to the current app version. If the cache is invalid or outdated,
  244. // it fetches fresh data from GitHub.
  245. //
  246. // - Returns: A tuple containing:
  247. // - latestVersion: The latest version string (if available).
  248. // - isNewer: `true` if the fetched version is newer than the current version.
  249. // - isBlacklisted: `true` if the current version is blacklisted.
  250. private func checkForNewVersion() async -> (String?, Bool, Bool) {
  251. let currentVersion = version()
  252. let now = Date()
  253. // Retrieve cached values.
  254. let lastChecked = latestVersionChecked ?? .distantPast
  255. let cachedVersion = cachedForVersion
  256. let persistedLatest = persistedLatestVersion
  257. let isBlacklistedCached = currentVersionBlackListed
  258. // If the current app version has changed, reset notification timestamps.
  259. if let cachedVersion = cachedVersion, cachedVersion != currentVersion {
  260. lastBlacklistNotificationShown = .distantPast
  261. lastVersionUpdateNotificationShown = .distantPast
  262. }
  263. // Use cached data if it is valid (less than 24 hours old) and matches the current version.
  264. if let cachedVersion = cachedVersion,
  265. cachedVersion == currentVersion,
  266. now.timeIntervalSince(lastChecked) < 24 * 3600,
  267. let persistedLatest = persistedLatest
  268. {
  269. let isNewer = isVersion(persistedLatest, newerThan: currentVersion)
  270. return (persistedLatest, isNewer, isBlacklistedCached)
  271. }
  272. // Otherwise, fetch fresh data from GitHub and update the cache.
  273. return await fetchDataAndUpdateCache(currentVersion: currentVersion)
  274. }
  275. // Fetches version and blacklist data from GitHub, updates persisted values, and returns the result.
  276. //
  277. // This method performs two parallel network requests: one for the version configuration and one for the
  278. // blacklisted versions. After parsing the fetched data and comparing version values, it updates the cache and
  279. // returns the results.
  280. //
  281. // - Parameters:
  282. // - currentVersion: The current app version.
  283. // - Returns: A tuple containing:
  284. // - latestVersion: The latest version string from GitHub (if available).
  285. // - isNewer: `true` if the fetched version is newer than the current version.
  286. // - isBlacklisted: `true` if the current version is blacklisted.
  287. private func fetchDataAndUpdateCache(currentVersion: String) async -> (String?, Bool, Bool) {
  288. // Fetch both data types in parallel
  289. async let versionData = fetchData(for: .versionConfig)
  290. async let blacklistData = fetchData(for: .blacklistedVersions)
  291. let (versionDataResult, blacklistDataResult) = await (versionData, blacklistData)
  292. // Parse the version from the fetched configuration data.
  293. let fetchedVersion = versionDataResult
  294. .flatMap { String(data: $0, encoding: .utf8) }
  295. .flatMap { self.parseVersionFromConfig(contents: $0) }
  296. // Determine if the fetched version is newer than the current version.
  297. let isNewer = fetchedVersion.map {
  298. self.isVersion($0, newerThan: currentVersion)
  299. } ?? false
  300. // Determine if the current version is blacklisted.
  301. let isBlacklisted = (try? blacklistDataResult.flatMap {
  302. try JSONDecoder().decode(Blacklist.self, from: $0)
  303. })?.blacklistedVersions
  304. .map(\.version)
  305. .contains(currentVersion) ?? false
  306. // Update persisted cache.
  307. persistedLatestVersion = fetchedVersion
  308. latestVersionChecked = Date()
  309. currentVersionBlackListed = isBlacklisted
  310. cachedForVersion = currentVersion
  311. return (fetchedVersion, isNewer, isBlacklisted)
  312. }
  313. // MARK: - Data Fetching Helper
  314. // Fetches data from GitHub for a specified data type.
  315. //
  316. // This helper method builds a URL from the provided GitHubDataType and executes a network request.
  317. // If the request is successful and returns valid data (HTTP status 200), the data is returned.
  318. //
  319. // - Parameters:
  320. // - dataType: The type of GitHub data to fetch (version configuration or blacklisted versions).
  321. // - Returns: The fetched data as an optional `Data` object.
  322. private func fetchData(for dataType: GitHubDataType) async -> Data? {
  323. guard let url = URL(string: dataType.url) else {
  324. return nil
  325. }
  326. do {
  327. let (data, response) = try await URLSession.shared.data(from: url)
  328. guard let httpResponse = response as? HTTPURLResponse,
  329. httpResponse.statusCode == 200
  330. else {
  331. return nil
  332. }
  333. return data
  334. } catch {
  335. return nil
  336. }
  337. }
  338. // Legacy completion handler version for existing code
  339. private func fetchData(for dataType: GitHubDataType, completion: @escaping (Data?) -> Void) {
  340. guard let url = URL(string: dataType.url) else {
  341. completion(nil)
  342. return
  343. }
  344. URLSession.shared.dataTask(with: url) { data, response, error in
  345. guard let data = data, error == nil,
  346. let httpResponse = response as? HTTPURLResponse,
  347. httpResponse.statusCode == 200
  348. else {
  349. completion(nil)
  350. return
  351. }
  352. completion(data)
  353. }.resume()
  354. }
  355. // MARK: - Helpers
  356. // Parses the version string from the contents of a configuration file.
  357. //
  358. // The method scans each line of the provided content for an occurrence of "APP_VERSION" and then
  359. // extracts the version number following the "=" delimiter.
  360. //
  361. // - Parameter contents: A string containing the contents of the configuration file.
  362. // - Returns: The extracted version string if found; otherwise, `nil`.
  363. private func parseVersionFromConfig(contents: String) -> String? {
  364. let lines = contents.split(separator: "\n")
  365. for line in lines {
  366. if line.contains("APP_VERSION"), !line.contains("DEV") {
  367. let components = line.split(separator: "=").map {
  368. $0.trimmingCharacters(in: .whitespacesAndNewlines)
  369. }
  370. if components.count > 1 {
  371. return components[1]
  372. }
  373. }
  374. }
  375. return nil
  376. }
  377. // Parses the dev version string from the contents of a configuration file.
  378. //
  379. // The method scans each line of the provided content for an occurrence of "APP_DEV_VERSION" and then
  380. // extracts the version number following the "=" delimiter.
  381. //
  382. // - Parameter contents: A string containing the contents of the configuration file.
  383. // - Returns: The extracted dev version string if found; otherwise, `nil`.
  384. private func parseDevVersionFromConfig(contents: String) -> String? {
  385. let lines = contents.split(separator: "\n")
  386. for line in lines {
  387. if line.contains("APP_DEV_VERSION") {
  388. let components = line.split(separator: "=").map {
  389. $0.trimmingCharacters(in: .whitespacesAndNewlines)
  390. }
  391. if components.count > 1 {
  392. return components[1]
  393. }
  394. }
  395. }
  396. return nil
  397. }
  398. // Compares two version strings to determine if the fetched version is newer than the current version.
  399. //
  400. // The version strings are split into numeric components and compared sequentially.
  401. // If any component of the fetched version is greater than its counterpart in the current version,
  402. // the function returns `true`; if lower, it returns `false`.
  403. //
  404. // - Parameters:
  405. // - fetchedVersion: The version string obtained from GitHub.
  406. // - currentVersion: The current app version.
  407. // - Returns: `true` if the fetched version is newer than the current version; otherwise, `false`.
  408. private func isVersion(_ fetchedVersion: String, newerThan currentVersion: String) -> Bool {
  409. let fetchedComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 }
  410. let currentComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 }
  411. let maxCount = max(fetchedComponents.count, currentComponents.count)
  412. for i in 0 ..< maxCount {
  413. let fetched = i < fetchedComponents.count ? fetchedComponents[i] : 0
  414. let current = i < currentComponents.count ? currentComponents[i] : 0
  415. if fetched > current {
  416. return true
  417. } else if fetched < current {
  418. return false
  419. }
  420. }
  421. return false
  422. }
  423. // Retrieves the current app version from the main bundle.
  424. //
  425. // - Returns: The current app version as defined in the app's Info.plist under "CFBundleShortVersionString",
  426. // or `"Unknown"` if not available.
  427. private func version() -> String {
  428. Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
  429. }
  430. // Presents an alert on the specified view controller with a given title and message.
  431. //
  432. // The alert is dispatched to the main thread to ensure UI updates occur correctly.
  433. //
  434. // - Parameters:
  435. // - viewController: The UIViewController on which the alert should be presented.
  436. // - title: The title text for the alert.
  437. // - message: The body message of the alert.
  438. private func showAlert(on viewController: UIViewController, title: String, message: String) {
  439. let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
  440. alert.addAction(UIAlertAction(title: "OK", style: .default))
  441. viewController.present(alert, animated: true)
  442. }
  443. }