diff --git a/src/Projects/BKData/Project.swift b/src/Projects/BKData/Project.swift index b298306b..a696cb9e 100644 --- a/src/Projects/BKData/Project.swift +++ b/src/Projects/BKData/Project.swift @@ -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( diff --git a/src/Projects/BKData/Sources/DataAssembly.swift b/src/Projects/BKData/Sources/DataAssembly.swift index 28bb79ff..ac54e2c5 100644 --- a/src/Projects/BKData/Sources/DataAssembly.swift +++ b/src/Projects/BKData/Sources/DataAssembly.swift @@ -3,6 +3,7 @@ import BKCore import BKDomain import Foundation +import FirebaseRemoteConfig public struct DataAssembly: Assembly { public init() {} @@ -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 diff --git a/src/Projects/BKData/Sources/Repository/DefaultRemoteConfigRepository.swift b/src/Projects/BKData/Sources/Repository/DefaultRemoteConfigRepository.swift new file mode 100644 index 00000000..ac0d50a4 --- /dev/null +++ b/src/Projects/BKData/Sources/Repository/DefaultRemoteConfigRepository.swift @@ -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 { + return Future { 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) + } +} diff --git a/src/Projects/BKDomain/Sources/DomainAssembly.swift b/src/Projects/BKDomain/Sources/DomainAssembly.swift index 3a15b441..dbf3d40b 100644 --- a/src/Projects/BKDomain/Sources/DomainAssembly.swift +++ b/src/Projects/BKDomain/Sources/DomainAssembly.swift @@ -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 diff --git a/src/Projects/BKDomain/Sources/Entity/RemoteAppVersion.swift b/src/Projects/BKDomain/Sources/Entity/RemoteAppVersion.swift new file mode 100644 index 00000000..9d34b1af --- /dev/null +++ b/src/Projects/BKDomain/Sources/Entity/RemoteAppVersion.swift @@ -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 + } +} diff --git a/src/Projects/BKDomain/Sources/Interface/Repository/RemoteConfigRepository.swift b/src/Projects/BKDomain/Sources/Interface/Repository/RemoteConfigRepository.swift new file mode 100644 index 00000000..cec3c0b6 --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Repository/RemoteConfigRepository.swift @@ -0,0 +1,8 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine +import Foundation + +public protocol RemoteConfigRepository { + func fetchRemoteAppVersions() -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/Interface/Usecase/FetchRemoteAppVersionUseCase.swift b/src/Projects/BKDomain/Sources/Interface/Usecase/FetchRemoteAppVersionUseCase.swift new file mode 100644 index 00000000..1375ae71 --- /dev/null +++ b/src/Projects/BKDomain/Sources/Interface/Usecase/FetchRemoteAppVersionUseCase.swift @@ -0,0 +1,9 @@ +// Copyright © 2025 Booket. All rights reserved + +import Combine +import Foundation + +/// Remote Config에서 앱 버전 정보(최신, 최소)를 가져옵니다. +public protocol FetchRemoteAppVersionUseCase { + func execute() -> AnyPublisher +} diff --git a/src/Projects/BKDomain/Sources/UseCase/DefaultFetchRemoteAppVersionUseCase.swift b/src/Projects/BKDomain/Sources/UseCase/DefaultFetchRemoteAppVersionUseCase.swift new file mode 100644 index 00000000..f4aa9e30 --- /dev/null +++ b/src/Projects/BKDomain/Sources/UseCase/DefaultFetchRemoteAppVersionUseCase.swift @@ -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 { + return repository.fetchRemoteAppVersions() + } +} diff --git a/src/Projects/BKPresentation/Sources/AppCoordinator.swift b/src/Projects/BKPresentation/Sources/AppCoordinator.swift index f2d8de5a..7221f7ad 100644 --- a/src/Projects/BKPresentation/Sources/AppCoordinator.swift +++ b/src/Projects/BKPresentation/Sources/AppCoordinator.swift @@ -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 = [] @@ -28,6 +29,7 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, onboardingCheckUseCase: OnboardingCheckUseCase, markOnboardingSeenUseCase: MarkOnboardingSeenUseCase, appVersionUseCase: AppVersionUseCase, + fetchRemoteAppVersionUseCase: FetchRemoteAppVersionUseCase, syncFCMTokenUseCase: SyncFCMTokenUseCase ) { self.navigationController = navigationController @@ -35,6 +37,7 @@ public final class AppCoordinator: Coordinator, AuthenticationRequiredNotifying, self.onboardingCheckUseCase = onboardingCheckUseCase self.markOnboardingSeenUseCase = markOnboardingSeenUseCase self.appVersionUseCase = appVersionUseCase + self.fetchRemoteAppVersionUseCase = fetchRemoteAppVersionUseCase self.syncFCMTokenUseCase = syncFCMTokenUseCase } @@ -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() } }) @@ -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() { diff --git a/src/Projects/Booket/Project.swift b/src/Projects/Booket/Project.swift index 150b2976..de6e56b1 100644 --- a/src/Projects/Booket/Project.swift +++ b/src/Projects/Booket/Project.swift @@ -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: [ @@ -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: [ diff --git a/src/Projects/Booket/Sources/SceneDelegate.swift b/src/Projects/Booket/Sources/SceneDelegate.swift index 9e18738d..1c21ad20 100644 --- a/src/Projects/Booket/Sources/SceneDelegate.swift +++ b/src/Projects/Booket/Sources/SceneDelegate.swift @@ -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( @@ -67,6 +68,7 @@ private extension SceneDelegate { onboardingCheckUseCase: onboardingCheckUseCase, markOnboardingSeenUseCase: markOnboardingSeenUseCase, appVersionUseCase: appVersionUseCase, + fetchRemoteAppVersionUseCase: fetchRemoteAppVersionUseCase, syncFCMTokenUseCase: syncFCMTokenUseCase ) coordinator?.start()