Explorar el Código

Merge pull request #328 from bjorkert/app-update-notification

App update notification
marv-out hace 1 año
padre
commit
5969e7fb01

+ 14 - 0
Trio.xcodeproj/project.pbxproj

@@ -504,6 +504,7 @@
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
 		DDA6E3222D25901100C2988C /* TempTargetHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E3212D25901100C2988C /* TempTargetHelpView.swift */; };
 		DDA6E3572D25988500C2988C /* ContactImageHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E3562D25988500C2988C /* ContactImageHelpView.swift */; };
+		DDA9AC092D672CF100E6F1A9 /* AppVersionChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA9AC082D672CEB00E6F1A9 /* AppVersionChecker.swift */; };
 		DDAA29832D2D1D93006546A1 /* AdjustmentsRootView+Overrides.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAA29822D2D1D7B006546A1 /* AdjustmentsRootView+Overrides.swift */; };
 		DDAA29852D2D1D9E006546A1 /* AdjustmentsRootView+TempTargets.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAA29842D2D1D98006546A1 /* AdjustmentsRootView+TempTargets.swift */; };
 		DDB37CC52D05048F00D99BF4 /* ContactImageStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB37CC42D05048F00D99BF4 /* ContactImageStorage.swift */; };
@@ -1195,6 +1196,8 @@
 		DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideHelpView.swift; sourceTree = "<group>"; };
 		DDA6E3212D25901100C2988C /* TempTargetHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetHelpView.swift; sourceTree = "<group>"; };
 		DDA6E3562D25988500C2988C /* ContactImageHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactImageHelpView.swift; sourceTree = "<group>"; };
+		DDA9AC082D672CEB00E6F1A9 /* AppVersionChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersionChecker.swift; sourceTree = "<group>"; };
+		DDA9AC0A2D678DAD00E6F1A9 /* blacklisted-versions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blacklisted-versions.json"; sourceTree = "<group>"; };
 		DDAA29822D2D1D7B006546A1 /* AdjustmentsRootView+Overrides.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentsRootView+Overrides.swift"; sourceTree = "<group>"; };
 		DDAA29842D2D1D98006546A1 /* AdjustmentsRootView+TempTargets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AdjustmentsRootView+TempTargets.swift"; sourceTree = "<group>"; };
 		DDB37CC22D05044D00D99BF4 /* ContactTrickEntryStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContactTrickEntryStored+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -1724,6 +1727,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3811DE9225C9D88200A708ED /* Appearance */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
@@ -1969,6 +1973,7 @@
 		388E594F25AD948C0019842D = {
 			isa = PBXGroup;
 			children = (
+				DDA9AC0A2D678DAD00E6F1A9 /* blacklisted-versions.json */,
 				CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */,
 				38F3783A2613555C009DB701 /* Config.xcconfig */,
 				BD1CF8B72C1A4A8400CB930A /* ConfigOverride.xcconfig */,
@@ -2934,6 +2939,14 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDA9AC072D67291600E6F1A9 /* AppVersionChecker */ = {
+			isa = PBXGroup;
+			children = (
+				DDA9AC082D672CEB00E6F1A9 /* AppVersionChecker.swift */,
+			);
+			path = AppVersionChecker;
+			sourceTree = "<group>";
+		};
 		DDC9B9962CFD2332003E7721 /* Nightscout */ = {
 			isa = PBXGroup;
 			children = (
@@ -3650,6 +3663,7 @@
 				DDA6E3572D25988500C2988C /* ContactImageHelpView.swift in Sources */,
 				38FE826D25CC8461001FF17A /* NightscoutAPI.swift in Sources */,
 				388358C825EEF6D200E024B2 /* BasalProfileEntry.swift in Sources */,
+				DDA9AC092D672CF100E6F1A9 /* AppVersionChecker.swift in Sources */,
 				3811DE0B25C9D32F00A708ED /* BaseView.swift in Sources */,
 				3811DE3225C9D49500A708ED /* HomeDataFlow.swift in Sources */,
 				DD32CFA22CC824E2003686D6 /* TrioRemoteControl+Helpers.swift in Sources */,

+ 8 - 0
Trio/Sources/Application/TrioApp.swift

@@ -106,6 +106,14 @@ import Swinject
             if newScenePhase == .background {
                 coreDataStack.save()
             }
+
+            if newScenePhase == .active {
+                if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+                   let rootVC = windowScene.windows.first(where: { $0.isKeyWindow })?.rootViewController
+                {
+                    AppVersionChecker.shared.checkAndNotifyVersionStatus(in: rootVC)
+                }
+            }
         }
         .backgroundTask(.appRefresh("com.trio.cleanup")) {
             await scheduleDatabaseCleaning()

+ 57 - 1
Trio/Sources/Modules/Settings/View/SettingsRootView.swift

@@ -5,6 +5,12 @@ import SwiftUI
 import Swinject
 
 extension Settings {
+    struct VersionInfo: Equatable {
+        var latestVersion: String?
+        var isUpdateAvailable: Bool
+        var isBlacklisted: Bool
+    }
+
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
@@ -18,6 +24,11 @@ extension Settings {
         @State var hintLabel: String?
         @State private var decimalPlaceholder: Decimal = 0.0
         @State private var booleanPlaceholder: Bool = false
+        @State private var versionInfo = VersionInfo(
+            latestVersion: nil,
+            isUpdateAvailable: false,
+            isBlacklisted: false
+        )
 
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
@@ -27,6 +38,37 @@ extension Settings {
             SettingItems.filteredItems(searchText: searchText)
         }
 
+        @ViewBuilder var versionInfoView: some View {
+            let latestVersion = versionInfo.latestVersion
+            if let version = latestVersion {
+                let updateColor: Color = versionInfo.isUpdateAvailable ? .orange : .green
+                let versionIconName = versionInfo.isUpdateAvailable ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
+
+                VStack(alignment: .leading, spacing: 4) {
+                    HStack {
+                        Text("Latest version: \(version)")
+                            .font(.footnote)
+                            .foregroundColor(updateColor)
+                        Image(systemName: versionIconName)
+                            .foregroundColor(updateColor)
+                    }
+                    if versionInfo.isBlacklisted {
+                        HStack {
+                            Text("Warning: Known issues. Update now.")
+                                .font(.footnote)
+                                .foregroundColor(.red)
+                            Image(systemName: "exclamationmark.octagon.fill")
+                                .foregroundColor(.red)
+                        }
+                    }
+                }
+            } else {
+                Text("Latest version: Fetching...")
+                    .font(.footnote)
+                    .foregroundColor(.secondary)
+            }
+        }
+
         var body: some View {
             List {
                 if searchText.isEmpty {
@@ -46,7 +88,7 @@ extension Settings {
                                         .frame(width: 50, height: 50)
                                         .cornerRadius(10)
                                         .padding(.trailing, 10)
-                                    VStack(alignment: .leading) {
+                                    VStack(alignment: .leading, spacing: 4) {
                                         Text("Trio v\(versionNumber) (\(buildNumber))")
                                             .font(.headline)
                                         if let expirationDate = buildDetails.calculateExpirationDate() {
@@ -63,6 +105,8 @@ extension Settings {
                                                 .font(.footnote)
                                                 .foregroundColor(.secondary)
                                         }
+
+                                        versionInfoView
                                     }
                                 }
                             }
@@ -312,6 +356,18 @@ extension Settings {
             }
             .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
             .screenNavigation(self)
+            .onAppear {
+                AppVersionChecker.shared.refreshVersionInfo { _, latestVersion, isNewer, isBlacklisted in
+                    let updateAvailable = isNewer
+                    DispatchQueue.main.async {
+                        versionInfo = VersionInfo(
+                            latestVersion: latestVersion,
+                            isUpdateAvailable: updateAvailable,
+                            isBlacklisted: isBlacklisted
+                        )
+                    }
+                }
+            }
         }
     }
 }

+ 342 - 0
Trio/Sources/Services/AppVersionChecker/AppVersionChecker.swift

@@ -0,0 +1,342 @@
+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 {
+    /// Shared singleton instance.
+    static let shared = AppVersionChecker()
+
+    /// Private initializer to enforce the singleton pattern.
+    private init() {}
+
+    // MARK: - Persisted Properties
+
+    /// Cached app version for which data was last fetched.
+    @Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
+    /// The latest version fetched from GitHub.
+    @Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
+    /// The date when the latest version was checked.
+    @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
+    /// Timestamp for the last time a blacklist notification was shown.
+    @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
+    /// Timestamp for the last time an expiration notification was shown.
+    @Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
+
+    // MARK: - Nested Types
+
+    /// GitHubDataType defines the type of data to fetch from GitHub for version checking.
+    private enum GitHubDataType {
+        /// The configuration file containing version information.
+        case versionConfig
+        /// The JSON file listing blacklisted versions.
+        case blacklistedVersions
+
+        /// Returns the URL string associated with the data type.
+        var url: String {
+            switch self {
+            case .versionConfig:
+                return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/Config.xcconfig"
+            case .blacklistedVersions:
+                return "https://raw.githubusercontent.com/nightscout/Trio/refs/heads/main/blacklisted-versions.json"
+            }
+        }
+    }
+
+    /// Model for decoding the blacklist JSON from GitHub.
+    private struct Blacklist: Decodable {
+        /// Array of blacklisted version entries.
+        let blacklistedVersions: [VersionEntry]
+    }
+
+    /// Model representing a single version entry in the blacklist.
+    private struct VersionEntry: Decodable {
+        /// The version string that is blacklisted.
+        let version: String
+    }
+
+    // MARK: - Public Methods
+
+    /**
+     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) {
+        checkForNewVersion { [weak viewController] latestVersion, isNewer, isBlacklisted in
+            guard let vc = viewController else { return }
+            let now = Date()
+
+            // If the current version is blacklisted, show a critical update alert if not shown in the last 24 hours.
+            if isBlacklisted {
+                let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
+                if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
+                    self.showAlert(
+                        on: vc,
+                        title: String(localized: "Update Required", comment: "Title for critical update alert"),
+                        message: String(
+                            localized: "The current version has a critical issue and should be updated as soon as possible.",
+                            comment: "Message for critical update alert"
+                        )
+                    )
+                    self.lastBlacklistNotificationShown = now
+                    self.lastVersionUpdateNotificationShown = now
+                }
+            }
+            // Otherwise, if a newer version is available, show an update alert if not shown in the last 2 weeks.
+            else if isNewer {
+                let lastShown = self.lastVersionUpdateNotificationShown ?? .distantPast
+                if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
+                    let versionText = latestVersion ?? String(localized: "Unknown", comment: "Fallback text for unknown version")
+                    self.showAlert(
+                        on: vc,
+                        title: String(localized: "Update Available", comment: "Title for update available alert"),
+                        message: String(
+                            localized: "A new version (\(versionText)) is available. It is recommended to update.",
+                            comment: "Message for update available alert"
+                        )
+                    )
+                    self.lastVersionUpdateNotificationShown = now
+                }
+            }
+        }
+    }
+
+    /**
+     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 (
+        String,
+        String?,
+        Bool,
+        Bool
+    ) -> Void) {
+        let currentVersion = version()
+        checkForNewVersion { latestVersion, isNewer, isBlacklisted in
+            completion(currentVersion, latestVersion, isNewer, isBlacklisted)
+        }
+    }
+
+    // MARK: - Core Version Checking Logic
+
+    /**
+     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) {
+        let currentVersion = version()
+        let now = Date()
+
+        // Retrieve cached values.
+        let lastChecked = latestVersionChecked ?? .distantPast
+        let cachedVersion = cachedForVersion
+        let persistedLatest = persistedLatestVersion
+        let isBlacklistedCached = currentVersionBlackListed
+
+        // If the current app version has changed, reset notification timestamps.
+        if let cachedVersion = cachedVersion, cachedVersion != currentVersion {
+            lastBlacklistNotificationShown = .distantPast
+            lastVersionUpdateNotificationShown = .distantPast
+        }
+
+        // Use cached data if it is valid (less than 24 hours old) and matches the current version.
+        if let cachedVersion = cachedVersion,
+           cachedVersion == currentVersion,
+           now.timeIntervalSince(lastChecked) < 24 * 3600,
+           let persistedLatest = persistedLatest
+        {
+            let isNewer = isVersion(persistedLatest, newerThan: currentVersion)
+            completion(persistedLatest, isNewer, isBlacklistedCached)
+            return
+        }
+
+        // Otherwise, fetch fresh data from GitHub and update the cache.
+        fetchDataAndUpdateCache(currentVersion: currentVersion, completion: 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) {
+        fetchData(for: .versionConfig) { versionData in
+            self.fetchData(for: .blacklistedVersions) { blacklistData in
+                DispatchQueue.main.async {
+                    // Parse the version from the fetched configuration data.
+                    let fetchedVersion = versionData
+                        .flatMap { String(data: $0, encoding: .utf8) }
+                        .flatMap { self.parseVersionFromConfig(contents: $0) }
+
+                    // Determine if the fetched version is newer than the current version.
+                    let isNewer = fetchedVersion.map {
+                        self.isVersion($0, newerThan: currentVersion)
+                    } ?? false
+
+                    // Determine if the current version is blacklisted.
+                    let isBlacklisted = (try? blacklistData.flatMap {
+                        try JSONDecoder().decode(Blacklist.self, from: $0)
+                    })?.blacklistedVersions
+                        .map(\.version)
+                        .contains(currentVersion) ?? false
+
+                    // Update persisted cache.
+                    self.persistedLatestVersion = fetchedVersion
+                    self.latestVersionChecked = Date()
+                    self.currentVersionBlackListed = isBlacklisted
+                    self.cachedForVersion = currentVersion
+
+                    completion(fetchedVersion, isNewer, isBlacklisted)
+                }
+            }
+        }
+    }
+
+    // MARK: - Data Fetching Helper
+
+    /**
+     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) {
+        guard let url = URL(string: dataType.url) else {
+            completion(nil)
+            return
+        }
+
+        URLSession.shared.dataTask(with: url) { data, response, error in
+            guard let data = data, error == nil,
+                  let httpResponse = response as? HTTPURLResponse,
+                  httpResponse.statusCode == 200
+            else {
+                completion(nil)
+                return
+            }
+            completion(data)
+        }.resume()
+    }
+
+    // MARK: - Helpers
+
+    /**
+     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? {
+        let lines = contents.split(separator: "\n")
+        for line in lines {
+            if line.contains("APP_VERSION") {
+                let components = line.split(separator: "=").map {
+                    $0.trimmingCharacters(in: .whitespacesAndNewlines)
+                }
+                if components.count > 1 {
+                    return components[1]
+                }
+            }
+        }
+        return nil
+    }
+
+    /**
+     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 {
+        let fetchedComponents = fetchedVersion.split(separator: ".").map { Int($0) ?? 0 }
+        let currentComponents = currentVersion.split(separator: ".").map { Int($0) ?? 0 }
+
+        let maxCount = max(fetchedComponents.count, currentComponents.count)
+        for i in 0 ..< maxCount {
+            let fetched = i < fetchedComponents.count ? fetchedComponents[i] : 0
+            let current = i < currentComponents.count ? currentComponents[i] : 0
+            if fetched > current {
+                return true
+            } else if fetched < current {
+                return false
+            }
+        }
+        return false
+    }
+
+    /**
+     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 {
+        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+    }
+
+    /**
+     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) {
+        DispatchQueue.main.async {
+            let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
+            alert.addAction(UIAlertAction(title: "OK", style: .default))
+            viewController.present(alert, animated: true)
+        }
+    }
+}

+ 10 - 0
blacklisted-versions.json

@@ -0,0 +1,10 @@
+{
+    "blacklistedVersions": [
+        {
+            "version": "0.0.1"
+        },
+        {
+            "version": "0.0.2"
+        }
+    ]
+}