Bläddra i källkod

Notification for new versions

Jonas Björkert 1 år sedan
förälder
incheckning
a5a89190de

+ 14 - 0
Trio.xcodeproj/project.pbxproj

@@ -501,6 +501,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 */; };
@@ -1236,6 +1237,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>"; };
@@ -1765,6 +1768,7 @@
 		3811DE9125C9D88200A708ED /* Services */ = {
 			isa = PBXGroup;
 			children = (
+				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
 				3811DE9225C9D88200A708ED /* Appearance */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
@@ -2010,6 +2014,7 @@
 		388E594F25AD948C0019842D = {
 			isa = PBXGroup;
 			children = (
+				DDA9AC0A2D678DAD00E6F1A9 /* blacklisted-versions.json */,
 				CE1F6DE62BAF1A180064EB8D /* BuildDetails.plist */,
 				38F3783A2613555C009DB701 /* Config.xcconfig */,
 				BD1CF8B72C1A4A8400CB930A /* ConfigOverride.xcconfig */,
@@ -2975,6 +2980,14 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDA9AC072D67291600E6F1A9 /* AppVersionChecker */ = {
+			isa = PBXGroup;
+			children = (
+				DDA9AC082D672CEB00E6F1A9 /* AppVersionChecker.swift */,
+			);
+			path = AppVersionChecker;
+			sourceTree = "<group>";
+		};
 		DDC9B9962CFD2332003E7721 /* Nightscout */ = {
 			isa = PBXGroup;
 			children = (
@@ -3684,6 +3697,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 */,

+ 12 - 4
Trio/Sources/Application/TrioApp.swift

@@ -67,20 +67,20 @@ import Swinject
             .default,
             "Trio Started: v\(Bundle.main.releaseVersionNumber ?? "")(\(Bundle.main.buildVersionNumber ?? "")) [buildDate: \(String(describing: BuildDetails.default.buildDate()))] [buildExpires: \(String(describing: BuildDetails.default.calculateExpirationDate()))]"
         )
-        
+
         // Setup up the Core Data Stack
         coreDataStack = CoreDataStack.shared
 
         do {
             // Explicitly initialize Core Data Stacak
             try coreDataStack.initializeStack()
-            
+
             // Load services
             loadServices()
-            
+
             // Fix bug in iOS 18 related to the translucent tab bar
             configureTabBarAppearance()
-            
+
             // Clear the persistentHistory and the NSManagedObjects that are older than 90 days every time the app starts
             cleanupOldData()
         } catch {
@@ -109,6 +109,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()

+ 2 - 2
Trio/Sources/Logger/Logger.swift

@@ -157,13 +157,13 @@ final class Logger {
             case .apsManager,
                  .bolusState,
                  .businessLogic,
+                 .coreData,
                  .deviceManager,
                  .nightscout,
                  .openAPS,
                  .remoteControl,
                  .service,
-                 .watchManager,
-                 .coreData:
+                 .watchManager:
                 return OSLog(subsystem: subsystem, category: name)
             }
         }

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

@@ -5,6 +5,11 @@ import SwiftUI
 import Swinject
 
 extension Settings {
+    struct VersionInfo: Equatable {
+        var latestVersion: String?
+        var isUpdateAvailable: Bool
+    }
+
     struct RootView: BaseView {
         let resolver: Resolver
         @StateObject var state = StateModel()
@@ -18,6 +23,10 @@ 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
+        )
 
         @Environment(\.colorScheme) var colorScheme
         @EnvironmentObject var appIcons: Icons
@@ -46,7 +55,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 +72,23 @@ extension Settings {
                                                 .font(.footnote)
                                                 .foregroundColor(.secondary)
                                         }
+                                        if let latest = versionInfo.latestVersion {
+                                            HStack {
+                                                Text("Latest version: \(latest)")
+                                                    .font(.footnote)
+                                                    .foregroundColor(versionInfo.isUpdateAvailable ? .orange : .green)
+                                                Image(
+                                                    systemName: versionInfo
+                                                        .isUpdateAvailable ? "exclamationmark.triangle.fill" :
+                                                        "checkmark.circle.fill"
+                                                )
+                                                .foregroundColor(versionInfo.isUpdateAvailable ? .orange : .green)
+                                            }
+                                        } else {
+                                            Text("Latest version: Fetching...")
+                                                .font(.footnote)
+                                                .foregroundColor(.secondary)
+                                        }
                                     }
                                 }
                             }
@@ -312,6 +338,17 @@ extension Settings {
             }
             .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
             .screenNavigation(self)
+            .onAppear {
+                AppVersionChecker.shared.refreshVersionInfo { _, latestVersion, isNewer, isBlacklisted in
+                    let updateAvailable = isNewer && !isBlacklisted
+                    DispatchQueue.main.async {
+                        versionInfo = VersionInfo(
+                            latestVersion: latestVersion,
+                            isUpdateAvailable: updateAvailable
+                        )
+                    }
+                }
+            }
         }
     }
 }

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

@@ -0,0 +1,230 @@
+import UIKit
+
+final class AppVersionChecker {
+    static let shared = AppVersionChecker()
+    private init() {}
+
+    // MARK: - Persisted Properties
+
+    @Persisted(key: "cachedForVersion") private var cachedForVersion: String? = nil
+    @Persisted(key: "latestVersion") private var persistedLatestVersion: String? = nil
+    @Persisted(key: "latestVersionChecked") private var latestVersionChecked: Date? = .distantPast
+    @Persisted(key: "currentVersionBlackListed") private var currentVersionBlackListed: Bool = false
+    @Persisted(key: "lastBlacklistNotificationShown") private var lastBlacklistNotificationShown: Date? = .distantPast
+    @Persisted(key: "lastVersionUpdateNotificationShown") private var lastVersionUpdateNotificationShown: Date? = .distantPast
+    @Persisted(key: "lastExpirationNotificationShown") private var lastExpirationNotificationShown: Date? = .distantPast
+
+    // MARK: - Nested Types
+
+    /// Types of data we fetch from GitHub for version checking.
+    private enum GitHubDataType {
+        case versionConfig
+        case blacklistedVersions
+
+        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"
+            }
+        }
+    }
+
+    /// Structures for decoding GitHub JSON data.
+    private struct Blacklist: Decodable {
+        let blacklistedVersions: [VersionEntry]
+    }
+
+    private struct VersionEntry: Decodable {
+        let version: String
+    }
+
+    // MARK: - Public Methods
+
+    /// Checks for new or blacklisted versions and presents an alert if needed.
+    func checkAndNotifyVersionStatus(in viewController: UIViewController) {
+        checkForNewVersion { [weak viewController] latestVersion, isNewer, isBlacklisted in
+            guard let vc = viewController else { return }
+            let now = Date()
+
+            // Check for critical (blacklisted) version.
+            if isBlacklisted {
+                let lastShown = self.lastBlacklistNotificationShown ?? .distantPast
+                if now.timeIntervalSince(lastShown) > 86400 { // 24 hours
+                    self.showAlert(
+                        on: vc,
+                        title: "Update Required",
+                        message: "The current version has a critical issue and should be updated as soon as possible."
+                    )
+                    self.lastBlacklistNotificationShown = now
+                    self.lastVersionUpdateNotificationShown = now
+                }
+            }
+            // Check for a new version available.
+            else if isNewer {
+                let lastShown = self.lastVersionUpdateNotificationShown ?? .distantPast
+                if now.timeIntervalSince(lastShown) > 1_209_600 { // 2 weeks
+                    let versionText = latestVersion ?? "Unknown"
+                    self.showAlert(
+                        on: vc,
+                        title: "Update Available",
+                        message: "A new version (\(versionText)) is available. It is recommended to update."
+                    )
+                    self.lastVersionUpdateNotificationShown = now
+                }
+            }
+        }
+    }
+
+    func refreshVersionInfo(completion: @escaping (
+        String /* currentVersion */,
+        String? /* latestVersion */,
+        Bool /* isNewer */,
+        Bool /* isBlacklisted */
+    ) -> Void) {
+        let currentVersion = version()
+        checkForNewVersion { latestVersion, isNewer, isBlacklisted in
+            completion(currentVersion, latestVersion, isNewer, isBlacklisted)
+        }
+    }
+
+    // MARK: - Core Version Checking Logic
+
+    /// Checks if there is a new or blacklisted version.
+    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
+
+        // Reset notifications if the current app version differs from the cached one.
+        if let cachedVersion = cachedVersion, cachedVersion != currentVersion {
+            lastBlacklistNotificationShown = .distantPast
+            lastVersionUpdateNotificationShown = .distantPast
+        }
+
+        // If cache is valid (<24 hours old) and for the current version, use it.
+        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 and update the cache.
+        fetchDataAndUpdateCache(currentVersion: currentVersion, completion: completion)
+    }
+
+    /// Fetches version and blacklist data from GitHub, updates persisted values, and then calls completion.
+    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 config.
+                    let fetchedVersion = versionData
+                        .flatMap { String(data: $0, encoding: .utf8) }
+                        .flatMap { self.parseVersionFromConfig(contents: $0) }
+
+                    // Compare versions.
+                    let isNewer = fetchedVersion.map {
+                        self.isVersion($0, newerThan: currentVersion)
+                    } ?? false
+
+                    // Parse and determine if 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 given data type.
+    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 a configuration file's content.
+    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.
+    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
+    }
+
+    /// Returns the current app version.
+    private func version() -> String {
+        Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown"
+    }
+
+    /// Presents an alert on the provided view controller.
+    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"
+        }
+    ]
+}