Compare commits

...

9 Commits

Author SHA1 Message Date
2679f31c4e update 2025-12-18 07:59:58 +03:00
1e61409501 patch link open 2025-12-18 07:52:56 +03:00
1269a29ae8 patch 2025-12-18 07:43:17 +03:00
4442dfc821 fix 2025-12-18 07:39:41 +03:00
cb0a8f249e update 2025-12-18 07:29:39 +03:00
216b8f50be patch 2025-12-18 07:05:46 +03:00
eef02dcca1 patch 2025-12-18 03:08:18 +03:00
43a9d477a9 patch 2025-12-18 02:46:12 +03:00
dd61970357 add notice update 2025-12-18 02:41:11 +03:00
6 changed files with 410 additions and 35 deletions

View File

@ -120,6 +120,42 @@
"Email не подтверждён. Подтвердите, чтобы активировать дополнительные проверки." : {
"comment" : "Описание необходимости подтверждения email"
},
"ForceUpdate.Message" : {
"comment" : "Force update alert message",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "This version is no longer supported. Please update the app."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Эта версия приложения устарела и больше не поддерживается. Пожалуйста, обновите приложение."
}
}
}
},
"ForceUpdate.Title" : {
"comment" : "Force update alert title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Update required"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Требуется обновление"
}
}
}
},
"Fun Fest" : {
"comment" : "Fun Fest",
"localizations" : {
@ -204,6 +240,42 @@
},
"Qr" : {
},
"SoftUpdate.Message" : {
"comment" : "Soft update alert message",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "A new version is available. Some features may stop working correctly soon."
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Вышла новая версия приложения. Некоторые функции могут скоро работать некорректно."
}
}
}
},
"SoftUpdate.Title" : {
"comment" : "Soft update alert title",
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Update available"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "Доступно обновление"
}
}
}
},
"Yobble" : {
"localizations" : {
@ -510,6 +582,9 @@
},
"Выключено" : {
},
"Вышла новая версия приложения с улучшениями и исправлениями." : {
"comment" : "Soft update alert message"
},
"Где найти сохранённые черновики?" : {
"comment" : "FAQ question: drafts"
@ -572,6 +647,9 @@
},
"Для начала, мы рекомендуем настроить параметры безопасности вашего аккаунта." : {
},
"Для продолжения работы необходимо обновить приложение до последней версии." : {
"comment" : "Need update alert message"
},
"Добавить в контакты" : {
"comment" : "Message profile add to contacts title"
@ -605,6 +683,9 @@
"Дополнительные действия." : {
"comment" : "Message profile more action description"
},
"Доступно обновление" : {
"comment" : "Soft update alert title"
},
"Другие устройства (%d)" : {
"comment" : "Заголовок секции других устройств с количеством"
},
@ -1767,6 +1848,12 @@
}
}
},
"Обновить приложение" : {
},
"Обновление обязательно" : {
"comment" : "Need update alert title"
},
"Обратная связь" : {
"comment" : "feedback: navigation title",
"localizations" : {
@ -2672,6 +2759,9 @@
"Рейтинг собеседника" : {
"comment" : "Message profile rating title"
},
"Рекомендуется обновление" : {
"comment" : "Force update alert title"
},
"Рожки и ножки у сообщений" : {
},
@ -3231,6 +3321,9 @@
},
"Экспериментальная поддержка iOS 15/16" : {
},
"Эта версия приложения устарела. Некоторые функции могут работать некорректно." : {
"comment" : "Force update alert message"
},
"Это устройство" : {
"comment" : "Заголовок секции текущего устройства"

View File

@ -0,0 +1,186 @@
import Foundation
import SwiftUI
import UIKit
struct AppUpdateNotice: Identifiable {
enum Kind {
case need
case force
case soft
}
let id = UUID()
let kind: Kind
let appStoreURL: URL
var title: String {
switch kind {
case .need:
return NSLocalizedString("Обновление обязательно", comment: "Need update alert title")
case .force:
return NSLocalizedString("Рекомендуется обновление", comment: "Force update alert title")
case .soft:
return NSLocalizedString("Доступно обновление", comment: "Soft update alert title")
}
}
var message: String {
switch kind {
case .need:
return NSLocalizedString("Для продолжения работы необходимо обновить приложение до последней версии.", comment: "Need update alert message")
case .force:
return NSLocalizedString("Эта версия приложения устарела. Некоторые функции могут работать некорректно.", comment: "Force update alert message")
case .soft:
return NSLocalizedString("Вышла новая версия приложения с улучшениями и исправлениями.", comment: "Soft update alert message")
}
}
}
final class AppUpdateChecker: ObservableObject {
@AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
@AppStorage("lastCheckedAppBuild") private var lastCheckedAppBuild: Int = 0
@Published private(set) var needUpdateNotice: AppUpdateNotice?
@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(link overrideURL: URL? = nil) {
guard let url = overrideURL
?? needUpdateNotice?.appStoreURL
?? 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/\(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
}
needUpdateNotice = nil
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 requiresNeedUpdate = buildNumber <= config.notSupportedBuild
if requiresNeedUpdate {
isAppBlocked = true
needUpdateNotice = AppUpdateNotice(kind: .need, appStoreURL: appStoreURL)
return
} else {
isAppBlocked = false
}
let requiresForcedUpdate = buildNumber < config.minSupportedBuild
if requiresForcedUpdate {
softUpdateNotice = AppUpdateNotice(kind: .force, appStoreURL: appStoreURL)
return
}
if buildNumber < config.recommendedBuild && config.recommendedBuild != lastCheckedAppBuild {
// lastCheckedAppBuild = config.recommendedBuild
softUpdateNotice = AppUpdateNotice(kind: .soft, 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 {
let schemaVersion: Int
let notSupportedBuild: Int
let minSupportedBuild: Int
let recommendedBuild: Int
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 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)
if let urlString = try container.decodeIfPresent(String.self, forKey: .appStoreURL) {
appStoreURL = URL(string: urlString)
} else {
appStoreURL = nil
}
}
}

View File

@ -7,8 +7,11 @@
import Foundation
import Combine
import SwiftUI
class LoginViewModel: ObservableObject {
// @AppStorage("appIsBlocked") private var isAppBlocked: Bool = false
@Published var username: String = ""
@Published var userId: String = ""
@Published var password: String = ""

View File

@ -0,0 +1,62 @@
import SwiftUI
struct NeedUpdateView: View {
let title: String
let message: String
let onUpdate: () -> Void
var body: some View {
ZStack {
LinearGradient(
colors: [Color(.systemBackground), Color(.systemGray6)],
startPoint: .top,
endPoint: .bottom
)
.ignoresSafeArea()
VStack(spacing: 24) {
Spacer()
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 56, weight: .bold))
.foregroundColor(.orange)
.accessibilityHidden(true)
VStack(spacing: 12) {
Text(title)
.font(.title2.weight(.semibold))
.multilineTextAlignment(.center)
Text(message)
.font(.body)
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
Button(action: onUpdate) {
Text(NSLocalizedString("Обновить приложение", comment: ""))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(14)
}
Spacer()
}
.padding(32)
.frame(maxWidth: 480)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .contain)
}
}
struct NeedUpdateView_Previews: PreviewProvider {
static var previews: some View {
NeedUpdateView(title: "Требуется обновление",
message: "Эта версия приложения устарела и больше не поддерживается.",
onUpdate: {})
}
}

View File

@ -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"

View File

@ -16,47 +16,77 @@ 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 {
WindowGroup {
ZStack(alignment: .top) {
Group {
if viewModel.isInitialLoading {
SplashScreenView()
} else if viewModel.isLoggedIn {
MainView(viewModel: viewModel)
} else {
LoginView(viewModel: viewModel)
}
}
if let banner = messageCenter.banner {
NewMessageBannerView(
banner: banner,
onOpen: { messageCenter.openCurrentChat() },
onDismiss: { messageCenter.dismissBanner() }
Group {
if let notice = updateChecker.needUpdateNotice {
NeedUpdateView(
title: notice.title,
message: notice.message,
onUpdate: { updateChecker.openAppStore(link: notice.appStoreURL) }
)
.padding(.horizontal, 16)
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
} else {
ZStack(alignment: .top) {
Group {
if viewModel.isInitialLoading {
SplashScreenView()
} else if viewModel.isLoggedIn {
MainView(viewModel: viewModel)
} else {
LoginView(viewModel: viewModel)
}
}
if let banner = messageCenter.banner {
NewMessageBannerView(
banner: banner,
onOpen: { messageCenter.openCurrentChat() },
onDismiss: { messageCenter.dismissBanner() }
)
.padding(.horizontal, 16)
.padding(.top, 12)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
}
}
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: messageCenter.banner != nil)
.sheet(item: AppConfig.PRESENT_CHAT_AS_SHEET ? $messageCenter.presentedChat : .constant(nil)) { chatItem in
NavigationView {
PrivateChatView(
chat: chatItem,
currentUserId: messageCenter.currentUserId
)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(NSLocalizedString("Закрыть", comment: "")) {
messageCenter.presentedChat = nil
}
}
}
}
}
.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(link: notice.appStoreURL)
},
secondaryButton: .cancel(Text(NSLocalizedString("Позже", comment: ""))) {
updateChecker.dismissSoftUpdateIfNeeded()
}
)
}
}
}
.environmentObject(messageCenter)
@ -64,6 +94,7 @@ 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