diff --git a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj index ec9db3ef..2967de7b 100644 --- a/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj +++ b/ByeBoo-iOS/ByeBoo-iOS.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 183EE0E12ED1D10600154F40 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 183EE0E02ED1D10600154F40 /* FirebaseMessaging */; }; 186847F02ED5567D002827DA /* FirebaseCore in Frameworks */ = {isa = PBXBuildFile; productRef = 186847EF2ED5567D002827DA /* FirebaseCore */; }; 18A8172D2E154F3D00F68F9F /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = 18A8172C2E154F3D00F68F9F /* Alamofire */; }; + 514D021B2FF6ADDD0041A4E1 /* FirebaseRemoteConfig in Frameworks */ = {isa = PBXBuildFile; productRef = 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */; }; 515C127F2E159D45001BA2E0 /* Then in Frameworks */ = {isa = PBXBuildFile; productRef = 515C127E2E159D45001BA2E0 /* Then */; }; 515C12822E159D77001BA2E0 /* SnapKit in Frameworks */ = {isa = PBXBuildFile; productRef = 515C12812E159D77001BA2E0 /* SnapKit */; }; 51691DED2E5CB059008CCEC6 /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 51691DEC2E5CB059008CCEC6 /* KakaoSDKAuth */; }; @@ -77,6 +78,7 @@ CBB032A72E7925FC00C0855E /* Mixpanel in Frameworks */, 51691DED2E5CB059008CCEC6 /* KakaoSDKAuth in Frameworks */, CB71A8A32F3B6F9400176B64 /* FirebaseCrashlytics in Frameworks */, + 514D021B2FF6ADDD0041A4E1 /* FirebaseRemoteConfig in Frameworks */, 183EE0E12ED1D10600154F40 /* FirebaseMessaging in Frameworks */, 183EE0DB2ED1CB0E00154F40 /* FirebaseAnalytics in Frameworks */, ); @@ -151,6 +153,7 @@ 183EE0E02ED1D10600154F40 /* FirebaseMessaging */, 186847EF2ED5567D002827DA /* FirebaseCore */, CB71A8A22F3B6F9400176B64 /* FirebaseCrashlytics */, + 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */, ); productName = "ByeBoo-iOS"; productReference = CB41808B2E0925D2001E7BCB /* ByeBoo-iOS.app */; @@ -671,6 +674,11 @@ package = 18A8172B2E154F3D00F68F9F /* XCRemoteSwiftPackageReference "Alamofire" */; productName = Alamofire; }; + 514D021A2FF6ADDD0041A4E1 /* FirebaseRemoteConfig */ = { + isa = XCSwiftPackageProductDependency; + package = 183EE0D92ED1CB0E00154F40 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseRemoteConfig; + }; 515C127E2E159D45001BA2E0 /* Then */ = { isa = XCSwiftPackageProductDependency; package = 515C127D2E159D45001BA2E0 /* XCRemoteSwiftPackageReference "Then" */; diff --git a/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift new file mode 100644 index 00000000..cc4a0be2 --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Data/Persistence/Service/ForceUpdateManager.swift @@ -0,0 +1,40 @@ +// +// RemoteConfigeManager.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/2/26. +// + +import FirebaseRemoteConfig + +final class DefaultForceUpdateService: ForceUpdateInterface { + let remoteConfig = RemoteConfig.remoteConfig() + + init() { + let settings = RemoteConfigSettings() + settings.minimumFetchInterval = 0 + remoteConfig.configSettings = settings + } + + func checkForUpdate() async -> Bool { + do { + try await remoteConfig.fetch() + try await remoteConfig.activate() + + let minimumVersion = remoteConfig["min_version_code"].stringValue + + return isUpdateRequired(minimumVersion: minimumVersion) + } catch { + return false + } + } + + private var currentAppVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0" + } + + private func isUpdateRequired(minimumVersion: String) -> Bool { + ByeBooLogger.debug("현재버전: \(currentAppVersion), 최소버전: \(minimumVersion)") + return currentAppVersion.compare(minimumVersion, options: .numeric) == .orderedAscending + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift index 67e7c71f..648e046e 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/DomainDependencyAssembler.swift @@ -255,5 +255,9 @@ struct DomainDependencyAssembler: DependencyAssembler { DIContainer.shared.register(type: ReadAllNotificationsUseCase.self) { _ in return DefaultReadAllNotificationsUseCase(repository: notificationRepository) } + + DIContainer.shared.register(type: CheckForceUpdateUseCase.self) { _ in + return DefaultCheckForceUpdateUseCase(repository: DefaultForceUpdateService()) + } } } diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift new file mode 100644 index 00000000..9e0cc6ac --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/Interface/ForeceUpdateInterface.swift @@ -0,0 +1,10 @@ +// +// ForeceUpdateInterface.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/5/26. +// + +protocol ForceUpdateInterface { + func checkForUpdate() async -> Bool +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift new file mode 100644 index 00000000..e9dc4e2b --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Domain/UseCase/CheckForceUpdateUseCase.swift @@ -0,0 +1,25 @@ +// +// CheckForceUpdateUseCase.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +import Foundation + +protocol CheckForceUpdateUseCase { + func execute() async -> Bool +} + +struct DefaultCheckForceUpdateUseCase: CheckForceUpdateUseCase { + + private let repository: ForceUpdateInterface + + init(repository: ForceUpdateInterface) { + self.repository = repository + } + + func execute() async -> Bool { + return await repository.checkForUpdate() + } +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift new file mode 100644 index 00000000..64d5f6ea --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Common/Modal/ForceUpdateModalView.swift @@ -0,0 +1,60 @@ +// +// ForceUpdateModalView.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +import UIKit + +import SnapKit +import Then + +final class ForceUpdateModalView: BaseView, ModalProtocol { + let actionButton: ByeBooButton = ByeBooButton(titleText: "업데이트 하러 가기", type: .enabled) + let dismissButton: ByeBooButton? = nil + let modalType: ConfirmModalType? = nil + + private let titleLabel = UILabel() + private let descriptionLabel = UILabel() + + override func setUI() { + addSubviews(titleLabel, descriptionLabel, actionButton) + } + + override func setStyle() { + backgroundColor = .grayscale900 + layer.cornerRadius = 12 + + titleLabel.applyByeBooFont( + style: .sub3M18, + text: "새로운 업데이트가 있어요", + color: .grayscale50 + ) + + descriptionLabel.applyByeBooFont( + style: .body3R16, + text: "더 나은 바이부를 만나보세요", + color: .grayscale400 + ) + } + + override func setLayout() { + titleLabel.snp.makeConstraints { + $0.top.equalToSuperview().inset(24.adjustedH) + $0.centerX.equalToSuperview() + } + descriptionLabel.snp.makeConstraints { + $0.top.equalTo(titleLabel.snp.bottom).offset(16.adjustedH) + $0.centerX.equalToSuperview() + } + actionButton.snp.makeConstraints { + $0.top.equalTo(descriptionLabel.snp.bottom).offset(16.adjustedH) + $0.centerX.equalToSuperview() + $0.horizontalEdges.equalToSuperview().inset(24.adjustedW) + $0.bottom.equalToSuperview().inset(24.adjustedH) + $0.height.equalTo(53.adjustedH) + } + } +} + diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift new file mode 100644 index 00000000..1ab8cedf --- /dev/null +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Enum/AppConstants.swift @@ -0,0 +1,11 @@ +// +// AppConstants.swift +// ByeBoo-iOS +// +// Created by 이나연 on 7/4/26. +// + +enum AppConstants { + static let appStoreID = "6748205348" + static let appStoreURL = "itms-apps://itunes.apple.com/app/id\(appStoreID)" +} diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift index 36807631..c4e2f421 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewController/SplashViewController.swift @@ -9,26 +9,33 @@ import Combine import UIKit final class SplashViewController: BaseViewController { - + private let rootView = SplashView() private let viewModel: SplashViewModel private var cancellables = Set() - + private var isFirstLaunch = true + init(viewModel: SplashViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { view = rootView viewModel.action(.viewDidLoad) bind() - setAddTarget() + NotificationCenter.default.addObserver( + self, + selector: #selector(appDidBecomeActive), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in guard let self = self else { return } if self.cancellables.isEmpty { @@ -39,8 +46,27 @@ final class SplashViewController: BaseViewController { } extension SplashViewController { - private func bind() { + bindCheckForceUpdate() + bindAutoLogin() + } + + private func bindCheckForceUpdate() { + viewModel.output.forceUpdatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + guard let self else { return } + switch result { + case true: + self.presentForceUpdateModal() + case false: + ByeBooLogger.debug("강제업데이트 필요 없음") + } + } + .store(in: &cancellables) + } + + private func bindAutoLogin() { viewModel.output.autoLoginPublisher .receive(on: DispatchQueue.main) .sink { result in @@ -77,6 +103,29 @@ extension SplashViewController { } .store(in: &cancellables) } + + private func presentForceUpdateModal() { + let modal = ModalBuilder( + modalView: ForceUpdateModalView(), + action: openAppstore, + rootViewController: self + ) + modal.present() + } + + private func openAppstore() { + guard let url = URL(string: AppConstants.appStoreURL) else { return } + UIApplication.shared.open(url) + } + + @objc + private func appDidBecomeActive() { + guard !isFirstLaunch else { + isFirstLaunch = false + return + } + viewModel.action(.viewDidLoad) + } } extension SplashViewController { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift index 4e880c44..284a05e1 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/Feature/Login/ViewModel/SplashViewModel.swift @@ -9,22 +9,27 @@ import Combine import Foundation final class SplashViewModel { - + private var autoLoginSubject: PassthroughSubject, Never> = .init() - + private var forceUpdateSubject: PassthroughSubject = .init() + var output: Output { Output( - autoLoginPublisher: autoLoginSubject.eraseToAnyPublisher() + autoLoginPublisher: autoLoginSubject.eraseToAnyPublisher(), + forceUpdatePublisher: forceUpdateSubject.eraseToAnyPublisher() ) } - + private var cancellables = Set() private let autoLoginUseCase: AutoLoginUseCase - + private let checkForceUpdateUseCase: CheckForceUpdateUseCase + init( - autoLoginUseCase: AutoLoginUseCase + autoLoginUseCase: AutoLoginUseCase, + checkForceUpdateUseCase: CheckForceUpdateUseCase ) { self.autoLoginUseCase = autoLoginUseCase + self.checkForceUpdateUseCase = checkForceUpdateUseCase } } @@ -36,18 +41,33 @@ extension SplashViewModel { struct Output { let autoLoginPublisher: AnyPublisher, Never> + let forceUpdatePublisher: AnyPublisher } func action(_ trigger: Input) { switch trigger { case .viewDidLoad: - autoLogin() + checkForceUpdate() } } } extension SplashViewModel { + private func checkForceUpdate() { + Task { + do { + if await checkForceUpdateUseCase.execute() { + forceUpdateSubject.send(true) + ByeBooLogger.debug("강제 업데이트 필요") + } else { + forceUpdateSubject.send(false) + autoLogin() + } + } + } + } + private func autoLogin() { ByeBooLogger.debug("자동로그인 실행") Task { diff --git a/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift b/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift index c94b6a22..a4f136fa 100644 --- a/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift +++ b/ByeBoo-iOS/ByeBoo-iOS/Presentation/PresentationDependencyAssembler.swift @@ -265,14 +265,15 @@ struct PresentationDependencyAssembler: DependencyAssembler { } DIContainer.shared.register(type: SplashViewModel.self) { container in - guard let autoLoginUseCase = container.resolve( - type: AutoLoginUseCase.self - ) - else { + guard let autoLoginUseCase = container.resolve(type: AutoLoginUseCase.self), + let checkForceUpdateUseCase = container.resolve(type: CheckForceUpdateUseCase.self) else { ByeBooLogger.error(ByeBooError.DIFailedError) return } - return SplashViewModel(autoLoginUseCase: autoLoginUseCase) + return SplashViewModel( + autoLoginUseCase: autoLoginUseCase, + checkForceUpdateUseCase: checkForceUpdateUseCase + ) } DIContainer.shared.register(type: FinishJourneyViewModel.self) { container in