From dd61970357d6a17040e574c8afeee813d80d0c67 Mon Sep 17 00:00:00 2001 From: cheykrym Date: Thu, 18 Dec 2025 02:41:11 +0300 Subject: [PATCH] add notice update --- yobble/Resources/Localizable.xcstrings | 3 + yobble/Services/AppUpdateChecker.swift | 164 +++++++++++++++++++++++++ yobble/Views/ForceUpdateView.swift | 50 ++++++++ yobble/config.swift | 2 +- yobble/yobbleApp.swift | 30 +++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 yobble/Services/AppUpdateChecker.swift create mode 100644 yobble/Views/ForceUpdateView.swift diff --git a/yobble/Resources/Localizable.xcstrings b/yobble/Resources/Localizable.xcstrings index 9931467..140618a 100644 --- a/yobble/Resources/Localizable.xcstrings +++ b/yobble/Resources/Localizable.xcstrings @@ -1766,6 +1766,9 @@ } } } + }, + "Обновить приложение" : { + }, "Обратная связь" : { "comment" : "feedback: navigation title", diff --git a/yobble/Services/AppUpdateChecker.swift b/yobble/Services/AppUpdateChecker.swift new file mode 100644 index 0000000..25c89cb --- /dev/null +++ b/yobble/Services/AppUpdateChecker.swift @@ -0,0 +1,164 @@ +import Foundation +import UIKit + +struct AppUpdateNotice: Identifiable { + enum Kind { + case force + case soft + } + + let id = UUID() + let kind: Kind + let title: String + let message: String + let appStoreURL: URL +} + +final class AppUpdateChecker: ObservableObject { + @Published private(set) var softUpdateNotice: AppUpdateNotice? + @Published private(set) var forceUpdateNotice: AppUpdateNotice? + + private let session: URLSession + private var didStartCheck = false + + init(session: URLSession = .shared) { + self.session = session + } + + func checkForUpdatesIfNeeded() { + guard !didStartCheck else { return } + didStartCheck = true + + Task { await fetchRemoteConfig() } + } + + func dismissSoftUpdateIfNeeded() { + softUpdateNotice = nil + } + + func openAppStore() { + guard let url = forceUpdateNotice?.appStoreURL ?? softUpdateNotice?.appStoreURL else { + return + } + + DispatchQueue.main.async { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + private func fetchRemoteConfig() async { + guard + let buildType = AppConfig.APP_BUILD.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), + let url = URL(string: "https://static.yobble.org/config/ios/dev2-\(buildType).json") + else { + log("Unable to build remote config URL") + return + } + + do { + let (data, response) = try await session.data(from: url) + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { + log("Unexpected response when fetching remote config") + return + } + + let decoder = JSONDecoder() + let remoteConfig = try decoder.decode(RemoteBuildConfiguration.self, from: data) + await MainActor.run { + self.apply(remoteConfig) + } + } catch { + log("Failed to fetch remote config: \(error)") + } + } + + @MainActor + private func apply(_ config: RemoteBuildConfiguration) { + guard let buildNumber = currentBuildNumber() else { + log("Unable to read current build number") + return + } + + forceUpdateNotice = nil + softUpdateNotice = nil + + guard let appStoreURL = config.appStoreURL else { + log("Config missing App Store URL") + return + } + + + print("buildNumber", buildNumber) + print("config", config.notSupportedBuild, config.minSupportedBuild, config.recommendedBuild) + let requiresDoUpdate = buildNumber <= config.notSupportedBuild + if requiresDoUpdate, let info = config.forceUpdate { + forceUpdateNotice = AppUpdateNotice(kind: .force, title: info.title, message: info.message, appStoreURL: appStoreURL) + return + } + + let requiresForcedUpdate = buildNumber < config.minSupportedBuild + if requiresForcedUpdate, let info = config.forceUpdate { + forceUpdateNotice = AppUpdateNotice(kind: .force, title: info.title, message: info.message, appStoreURL: appStoreURL) + return + } + + let needsSoftUpdate = buildNumber < config.recommendedBuild + if needsSoftUpdate, let info = config.softUpdate { + softUpdateNotice = AppUpdateNotice(kind: .soft, title: info.title, message: info.message, appStoreURL: appStoreURL) + return + } + } + + private func currentBuildNumber() -> Int? { + guard let rawValue = Bundle.main.infoDictionary?["CFBundleVersion"] as? String else { + return nil + } + return Int(rawValue) + } + + private func log(_ message: String) { + if AppConfig.DEBUG { + print("[AppUpdateChecker]", message) + } + } +} + +private struct RemoteBuildConfiguration: Decodable { + struct UpdateInfo: Decodable { + let title: String + let message: String + } + + let schemaVersion: Int + let notSupportedBuild: Int + let minSupportedBuild: Int + let recommendedBuild: Int + let forceUpdate: UpdateInfo? + let softUpdate: UpdateInfo? + let appStoreURL: URL? + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case notSupportedBuild = "not_supported_build" + case minSupportedBuild = "min_supported_build" + case recommendedBuild = "recommended_build" + case forceUpdate = "force_update" + case softUpdate = "soft_update" + case appStoreURL = "appstore_url" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + notSupportedBuild = try container.decode(Int.self, forKey: .notSupportedBuild) + minSupportedBuild = try container.decode(Int.self, forKey: .minSupportedBuild) + recommendedBuild = try container.decode(Int.self, forKey: .recommendedBuild) + forceUpdate = try container.decodeIfPresent(UpdateInfo.self, forKey: .forceUpdate) + softUpdate = try container.decodeIfPresent(UpdateInfo.self, forKey: .softUpdate) + if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) { + appStoreURL = URL(string: urlString) + } else { + appStoreURL = nil + } + } +} diff --git a/yobble/Views/ForceUpdateView.swift b/yobble/Views/ForceUpdateView.swift new file mode 100644 index 0000000..1cbe4e0 --- /dev/null +++ b/yobble/Views/ForceUpdateView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct ForceUpdateView: View { + let title: String + let message: String + let onUpdate: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.6) + .ignoresSafeArea() + + VStack(spacing: 16) { + Text(title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + + Text(message) + .font(.body) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + + Button(action: onUpdate) { + Text(NSLocalizedString("Обновить приложение", comment: "")) + .font(.headline) + .frame(maxWidth: .infinity) + .padding() + .background(Color.accentColor) + .foregroundColor(.white) + .cornerRadius(12) + } + } + .padding(24) + .background(.ultraThinMaterial) + .cornerRadius(20) + .padding(32) + } + .zIndex(2) + .accessibilityElement(children: .contain) + } +} + +struct ForceUpdateView_Previews: PreviewProvider { + static var previews: some View { + ForceUpdateView(title: "Требуется обновление", + message: "Эта версия приложения устарела и больше не поддерживается.", + onUpdate: {}) + } +} diff --git a/yobble/config.swift b/yobble/config.swift index 387a460..d6a9a42 100644 --- a/yobble/config.swift +++ b/yobble/config.swift @@ -1,7 +1,7 @@ import SwiftUI struct AppConfig { - static var DEBUG: Bool = true + static var DEBUG: Bool = false //static let SERVICE = Bundle.main.bundleIdentifier ?? "default.service" static let PROTOCOL = "https" static let API_SERVER = "\(PROTOCOL)://api.yobble.org" diff --git a/yobble/yobbleApp.swift b/yobble/yobbleApp.swift index 10b6b08..9d0e361 100644 --- a/yobble/yobbleApp.swift +++ b/yobble/yobbleApp.swift @@ -16,6 +16,7 @@ struct yobbleApp: App { @StateObject private var themeManager = ThemeManager() @StateObject private var viewModel = LoginViewModel() @StateObject private var messageCenter = IncomingMessageCenter() + @StateObject private var updateChecker = AppUpdateChecker() private let persistenceController = PersistenceController.shared var body: some Scene { @@ -64,11 +65,40 @@ struct yobbleApp: App { .preferredColorScheme(themeManager.theme.colorScheme) .environment(\.managedObjectContext, persistenceController.viewContext) .onAppear { + updateChecker.checkForUpdatesIfNeeded() messageCenter.currentUserId = viewModel.userId.isEmpty ? nil : viewModel.userId } .onChange(of: viewModel.userId) { newValue in messageCenter.currentUserId = newValue.isEmpty ? nil : newValue } + .alert(item: Binding( + get: { updateChecker.softUpdateNotice }, + set: { newValue in + if newValue == nil { + updateChecker.dismissSoftUpdateIfNeeded() + } + } + )) { notice in + Alert( + title: Text(notice.title), + message: Text(notice.message), + primaryButton: .default(Text(NSLocalizedString("Обновить", comment: ""))) { + updateChecker.openAppStore() + }, + secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) { + updateChecker.dismissSoftUpdateIfNeeded() + } + ) + } + .overlay(alignment: .center) { + if let notice = updateChecker.forceUpdateNotice { + ForceUpdateView( + title: notice.title, + message: notice.message, + onUpdate: { updateChecker.openAppStore() } + ) + } + } } } }