Skip to content
3 changes: 2 additions & 1 deletion src/Projects/BKData/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ let project = Project.project(
.domain(),
.external(dependency: .KakaoSDKCommon),
.external(dependency: .KakaoSDKAuth),
.external(dependency: .KakaoSDKUser)
.external(dependency: .KakaoSDKUser),
.external(dependency: .FirebaseRemoteConfig)
]
),
Target.target(
Expand Down
14 changes: 14 additions & 0 deletions src/Projects/BKData/Sources/DataAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import BKCore
import BKDomain
import Foundation
import FirebaseRemoteConfig

public struct DataAssembly: Assembly {
public init() {}
Expand Down Expand Up @@ -129,6 +130,19 @@ public struct DataAssembly: Assembly {
@Autowired var networkProvider: NetworkProvider
return DefaultAppStoreRepository(networkProvider: networkProvider)
}

container.register(
type: RemoteConfigRepository.self,
scope: .singleton) { _ in
let remoteConfig = RemoteConfig.remoteConfig()
let settings = RemoteConfigSettings()
#if DEBUG
settings.minimumFetchInterval = 0
#endif
remoteConfig.configSettings = settings
return DefaultRemoteConfigRepository(remoteConfig: remoteConfig)
}


container.register(
type: NotificationRepository.self
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright © 2025 Booket. All rights reserved

import BKDomain
import Combine
import Foundation
import FirebaseRemoteConfig

enum RemoteConfigKeys {
static let latestVersion = "appleLatestVersion"
static let minimumRequiredVersion = "appleMinimumVersion"
}

public struct DefaultRemoteConfigRepository: RemoteConfigRepository {

private let remoteConfig: RemoteConfig

public init(remoteConfig: RemoteConfig) {
self.remoteConfig = remoteConfig
self.setDefaults()
}

public func fetchRemoteAppVersions() -> AnyPublisher<RemoteAppVersion, Error> {
return Future<RemoteAppVersion, Error> { promise in
self.remoteConfig.fetchAndActivate { status, error in
if let error = error {
promise(.failure(error))
return
}

if status != .error {
let latest = self.remoteConfig.configValue(
forKey: RemoteConfigKeys.latestVersion
).stringValue

let minimum = self.remoteConfig.configValue(
forKey: RemoteConfigKeys.minimumRequiredVersion
).stringValue

let versions = RemoteAppVersion(
latestVersion: latest,
minimumRequiredVersion: minimum
)
promise(.success(versions))
} else {
promise(.failure(URLError(.cannotParseResponse)))
}
}
}
.eraseToAnyPublisher()
}

private func setDefaults() {
let defaultValues: [String: NSObject] = [
RemoteConfigKeys.latestVersion: "0.0.0" as NSObject,
RemoteConfigKeys.minimumRequiredVersion: "0.0.0" as NSObject
]
self.remoteConfig.setDefaults(defaultValues)
}
}
7 changes: 7 additions & 0 deletions src/Projects/BKDomain/Sources/DomainAssembly.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ public struct DomainAssembly: Assembly {
return DefaultAppVersionUseCase(repository: repository)
}

container.register(
type: FetchRemoteAppVersionUseCase.self
) { _ in
@Autowired var repository: RemoteConfigRepository
return DefaultFetchRemoteAppVersionUseCase(repository: repository)
}

container.register(
type: SearchBookUseCase.self
) { _ in
Expand Down
13 changes: 13 additions & 0 deletions src/Projects/BKDomain/Sources/Entity/RemoteAppVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright © 2025 Booket. All rights reserved

import Foundation

public struct RemoteAppVersion {
public let latestVersion: String
public let minimumRequiredVersion: String

public init(latestVersion: String, minimumRequiredVersion: String) {
self.latestVersion = latestVersion
self.minimumRequiredVersion = minimumRequiredVersion
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright © 2025 Booket. All rights reserved

import Combine
import Foundation

public protocol RemoteConfigRepository {
func fetchRemoteAppVersions() -> AnyPublisher<RemoteAppVersion, Error>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright © 2025 Booket. All rights reserved

import Combine
import Foundation

/// Remote Config에서 앱 버전 정보(최신, 최소)를 가져옵니다.
public protocol FetchRemoteAppVersionUseCase {
func execute() -> AnyPublisher<RemoteAppVersion, Error>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright © 2025 Booket. All rights reserved

import Combine
import Foundation

public struct DefaultFetchRemoteAppVersionUseCase: FetchRemoteAppVersionUseCase {

private let repository: RemoteConfigRepository

public init(repository: RemoteConfigRepository) {
self.repository = repository
}

public func execute() -> AnyPublisher<RemoteAppVersion, Error> {
return repository.fetchRemoteAppVersions()
}
}
68 changes: 50 additions & 18 deletions src/Projects/BKPresentation/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying,
private let onboardingCheckUseCase: OnboardingCheckUseCase
private let markOnboardingSeenUseCase: MarkOnboardingSeenUseCase
private let appVersionUseCase: AppVersionUseCase
private let fetchRemoteAppVersionUseCase: FetchRemoteAppVersionUseCase
private let syncFCMTokenUseCase: SyncFCMTokenUseCase
private var cancellable: Set<AnyCancellable> = []

Expand All @@ -28,13 +29,15 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying,
onboardingCheckUseCase: OnboardingCheckUseCase,
markOnboardingSeenUseCase: MarkOnboardingSeenUseCase,
appVersionUseCase: AppVersionUseCase,
fetchRemoteAppVersionUseCase: FetchRemoteAppVersionUseCase,
syncFCMTokenUseCase: SyncFCMTokenUseCase
) {
self.navigationController = navigationController
self.authStateUseCase = authStateUseCase
self.onboardingCheckUseCase = onboardingCheckUseCase
self.markOnboardingSeenUseCase = markOnboardingSeenUseCase
self.appVersionUseCase = appVersionUseCase
self.fetchRemoteAppVersionUseCase = fetchRemoteAppVersionUseCase
self.syncFCMTokenUseCase = syncFCMTokenUseCase
}

Expand Down Expand Up @@ -67,25 +70,34 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying,
private func checkAppUpdate() {
Publishers.Zip(
appVersionUseCase.execute().setFailureType(to: Error.self),
appVersionUseCase.executeRecentVersion()
fetchRemoteAppVersionUseCase.execute()
)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure = completion {
self?.proceedWithAppFlow()
}
}, receiveValue: { [weak self] currentVersionString, latestVersionString in
}, receiveValue: { [weak self] currentVersionString, remoteVersions in
guard let self = self,
let currentVersion = Version(currentVersionString),
let latestVersion = Version(latestVersionString)
let minimumVersion = Version(remoteVersions.minimumRequiredVersion),
let latestVersion = Version(remoteVersions.latestVersion)
else {
self?.proceedWithAppFlow()
return
}

if currentVersion.isMajorOrMinorUpdateRequired(from: latestVersion) {
self.presentUpdateSheet()
} else {
Log.debug("currentVersionString: \(currentVersionString)", logger: AppLogger.ui)
Log.debug("minimumRequiredVersion: \(remoteVersions.minimumRequiredVersion)", logger: AppLogger.ui)
Log.debug("latestVersion: \(remoteVersions.latestVersion)", logger: AppLogger.ui)

if currentVersion < minimumVersion {
self.presentUpdateSheet() // 강업
}
else if currentVersion < latestVersion {
self.presentUpdateSheet(isForced: false) // 권장
}
else {
self.proceedWithAppFlow()
}
})
Expand Down Expand Up @@ -203,21 +215,41 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying,
.store(in: &cancellable)
}

private func presentUpdateSheet() {
let dialog = BKDialog(
title: "최신 버전이 출시되었습니다",
subtitle: "최적의 사용 환경을 위해 업데이트해주세요.",
config: .init(
leftButtonTitle: "업데이트 하기",
leftButtonAction: AppStoreLinker.openAppStore
private func presentUpdateSheet(isForced: Bool = true) {
var dialog: BKDialog?

if isForced {
dialog = BKDialog(
title: "최신 버전이 출시되었습니다",
subtitle: "최적의 사용 환경을 위해 업데이트해주세요.",
config: .init(
leftButtonTitle: "업데이트 하기",
leftButtonAction: AppStoreLinker.openAppStore
)
)
)

} else {
// TODO(dyk) : 디자인 파트와 논의 후 subtitle 수정하기
dialog = BKDialog(
title: "최신 버전이 출시되었습니다",
subtitle: "최적의 사용 환경을 위해 업데이트해주세요.",
config: .init(
leftButtonTitle: "업데이트 하기",
leftButtonAction: AppStoreLinker.openAppStore,
rightButtonTitle: "나중에 하기",
rightButtonAction: { [weak self] in
guard let self else { return }
self.navigationController.dismiss(animated: true) {
self.proceedWithAppFlow()
}
}
)
)
}

guard let dialog else { return }
let dialogViewController = BKDialogViewController(dialog: dialog)
dialogViewController.isModalInPresentation = true
DispatchQueue.main.async {
self.navigationController.present(dialogViewController, animated: true)
}
navigationController.present(dialogViewController, animated: true)
}

private func requestNotificationPermissionIfNeeded() {
Expand Down
6 changes: 4 additions & 2 deletions src/Projects/Booket/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ let debugAppTarget = Target.target(
.external(dependency: .FirebaseCore),
.external(dependency: .FirebaseCrashlytics),
.external(dependency: .FirebaseAnalytics),
.external(dependency: .FirebaseMessaging)
.external(dependency: .FirebaseMessaging),
.external(dependency: .FirebaseRemoteConfig)
],
settings: .settings(
base: [
Expand Down Expand Up @@ -76,7 +77,8 @@ let releaseAppTarget = Target.target(
.external(dependency: .FirebaseCore),
.external(dependency: .FirebaseCrashlytics),
.external(dependency: .FirebaseAnalytics),
.external(dependency: .FirebaseMessaging)
.external(dependency: .FirebaseMessaging),
.external(dependency: .FirebaseRemoteConfig)
],
settings: .settings(
base: [
Expand Down
2 changes: 2 additions & 0 deletions src/Projects/Booket/Sources/SceneDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ private extension SceneDelegate {
@Autowired var onboardingCheckUseCase: OnboardingCheckUseCase
@Autowired var markOnboardingSeenUseCase: MarkOnboardingSeenUseCase
@Autowired var appVersionUseCase: AppVersionUseCase
@Autowired var fetchRemoteAppVersionUseCase: FetchRemoteAppVersionUseCase
@Autowired var syncFCMTokenUseCase: SyncFCMTokenUseCase

self.coordinator = AppCoordinator(
Expand All @@ -67,6 +68,7 @@ private extension SceneDelegate {
onboardingCheckUseCase: onboardingCheckUseCase,
markOnboardingSeenUseCase: markOnboardingSeenUseCase,
appVersionUseCase: appVersionUseCase,
fetchRemoteAppVersionUseCase: fetchRemoteAppVersionUseCase,
syncFCMTokenUseCase: syncFCMTokenUseCase
)
coordinator?.start()
Expand Down