Compare commits
9 Commits
84a8706b0a
...
2679f31c4e
| Author | SHA1 | Date | |
|---|---|---|---|
| 2679f31c4e | |||
| 1e61409501 | |||
| 1269a29ae8 | |||
| 4442dfc821 | |||
| cb0a8f249e | |||
| 216b8f50be | |||
| eef02dcca1 | |||
| 43a9d477a9 | |||
| dd61970357 |
@ -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" : "Заголовок секции текущего устройства"
|
||||
|
||||
186
yobble/Services/AppUpdateChecker.swift
Normal file
186
yobble/Services/AppUpdateChecker.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 = ""
|
||||
|
||||
62
yobble/Views/NeedUpdateView.swift
Normal file
62
yobble/Views/NeedUpdateView.swift
Normal 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: {})
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user