diff --git a/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/InfoPlist.swift index cbf692c6..84ee98d8 100644 --- a/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -13,6 +13,8 @@ public enum IdleInfoPlist { "CFBundleDisplayName": "케어밋", + "CFBundleShortVersionString" : "1.0.0", + "NSAppTransportSecurity" : [ "NSAllowsArbitraryLoads" : true ], diff --git a/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/TargetScripts.swift b/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/TargetScripts.swift new file mode 100644 index 00000000..b77038b3 --- /dev/null +++ b/project/Plugins/ConfigurationPlugin/ProjectDescriptionHelpers/TargetScripts.swift @@ -0,0 +1,29 @@ +// +// TargetScripts.swift +// ConfigurationPlugin +// +// Created by choijunios on 9/14/24. +// + +import Foundation +import ProjectDescription + +// MARK: Firebase crashlytics + +public extension TargetScript { + + static let crashlyticsScript: TargetScript = .post( + script: """ + ROOT_DIR=\(ProcessInfo.processInfo.environment["TUIST_ROOT_DIR"] ?? "") + "${ROOT_DIR}/Tuist/.build/checkouts/firebase-ios-sdk/Crashlytics/run" + """, + name: "Firebase Crashlytics", + inputPaths: [ + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", + ], + basedOnDependencyAnalysis: false + ) +} diff --git a/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency.swift b/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency.swift index e4c67a36..3e33d3e8 100644 --- a/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency.swift +++ b/project/Plugins/DependencyPlugin/ProjectDescriptionHelpers/Dependency.swift @@ -44,6 +44,9 @@ public extension ModuleDependency { public static let FSCalendar: TargetDependency = .external(name: "FSCalendar") public static let NaverMapSDKForSPM: TargetDependency = .external(name: "Junios.NMapSDKForSPM") public static let KingFisher: TargetDependency = .external(name: "Kingfisher") + public static let FirebaseRemoteConfig: TargetDependency = .external(name: "FirebaseRemoteConfig") + public static let FirebaseCrashlytics: TargetDependency = .external(name: "FirebaseCrashlytics") + public static let FirebaseAnalytics: TargetDependency = .external(name: "FirebaseAnalytics") } } diff --git a/project/Projects/App/Project.swift b/project/Projects/App/Project.swift index 2f9f9405..5dfd820c 100644 --- a/project/Projects/App/Project.swift +++ b/project/Projects/App/Project.swift @@ -27,6 +27,9 @@ let project = Project( infoPlist: IdleInfoPlist.appDefault, sources: ["Sources/**"], resources: ["Resources/**"], + scripts: [ + .crashlyticsScript + ], dependencies: [ // Presentation @@ -39,7 +42,7 @@ let project = Project( D.Data.ConcreteRepository, // ThirdParty - D.ThirdParty.Swinject + D.ThirdParty.Swinject, ], settings: .settings( configurations: IdleConfiguration.appConfigurations diff --git a/project/Projects/App/Resources/GoogleService-Info.plist b/project/Projects/App/Resources/GoogleService-Info.plist new file mode 100644 index 00000000..e902d244 --- /dev/null +++ b/project/Projects/App/Resources/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyB4eId4P8P3XlpHIVINfVpyB-pMQmqNxQM + GCM_SENDER_ID + 740246202578 + PLIST_VERSION + 1 + BUNDLE_ID + com.idle.caremeet + PROJECT_ID + swm-3idiots + STORAGE_BUCKET + swm-3idiots.appspot.com + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:740246202578:ios:d5e59f4f2116f92342250b + + \ No newline at end of file diff --git a/project/Projects/App/Sources/AppDelegate.swift b/project/Projects/App/Sources/AppDelegate.swift index 2a996576..69eba4b1 100644 --- a/project/Projects/App/Sources/AppDelegate.swift +++ b/project/Projects/App/Sources/AppDelegate.swift @@ -9,18 +9,20 @@ import UIKit import AppTrackingTransparency import AdSupport import PresentationCore +import FirebaseCore @main class AppDelegate: UIResponder, UIApplicationDelegate { - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: { [weak self] in self?.requestTrackingAuthorization() }) + // FireBase setting + FirebaseApp.configure() + return true } diff --git a/project/Projects/App/Sources/DI/Assembly/DataAssembly.swift b/project/Projects/App/Sources/DI/Assembly/DataAssembly.swift index be84b0d0..7a337df4 100644 --- a/project/Projects/App/Sources/DI/Assembly/DataAssembly.swift +++ b/project/Projects/App/Sources/DI/Assembly/DataAssembly.swift @@ -46,5 +46,10 @@ public struct DataAssembly: Assembly { container.register(RecruitmentPostRepository.self) { _ in return DefaultRecruitmentPostRepository() } + + // MARK: RemoteConfig + container.register(RemoteConfigRepository.self) { _ in + return DefaultRemoteConfigRepository() + } } } diff --git a/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift b/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift index 159f158f..87e41458 100644 --- a/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift +++ b/project/Projects/App/Sources/RootCoordinator/RootCoordinator.swift @@ -38,7 +38,8 @@ class RootCoordinator { authUseCase: injector.resolve(AuthUseCase.self), workerProfileUseCase: injector.resolve(WorkerProfileUseCase.self), centerProfileUseCase: injector.resolve(CenterProfileUseCase.self), - userInfoLocalRepository: injector.resolve(UserInfoLocalRepository.self) + userInfoLocalRepository: injector.resolve(UserInfoLocalRepository.self), + remoteConfigRepository: injector.resolve(RemoteConfigRepository.self) ) vc.bind(viewModel: vm) diff --git a/project/Projects/Data/ConcreteRepository/RemoteConfig/asd.swift b/project/Projects/Data/ConcreteRepository/RemoteConfig/asd.swift new file mode 100644 index 00000000..18804d06 --- /dev/null +++ b/project/Projects/Data/ConcreteRepository/RemoteConfig/asd.swift @@ -0,0 +1,58 @@ +// +// asd.swift +// ConcreteRepository +// +// Created by choijunios on 9/13/24. +// + +import Foundation +import RepositoryInterface + +import RxSwift +import FirebaseRemoteConfig +import Entity + +public class DefaultRemoteConfigRepository: RemoteConfigRepository { + + private let remoteConfig = RemoteConfig.remoteConfig() + private let settings = RemoteConfigSettings() + + public init() { + remoteConfig.configSettings = settings + } + + public func fetchRemoteConfig() -> RxSwift.Single> { + + Single.create { [weak self] single in + + self?.remoteConfig.fetch { status, error in + + if status == .success { + self?.remoteConfig.activate() + single(.success(.success(true))) + } else { + single(.success(.success(false))) + } + } + + return Disposables.create { } + } + } + + public func getForceUpdateInfo() -> ForceUpdateInfo? { + let jsonData = remoteConfig["forceUpdate_iOS"].jsonValue + + if let jsonData { + + do { + let data = try JSONSerialization.data(withJSONObject: jsonData) + let decoded = try JSONDecoder().decode(ForceUpdateInfo.self, from: data) + return decoded + } catch { + + return nil + } + } + return nil + } +} diff --git a/project/Projects/Data/DataSource/API/AuthAPI.swift b/project/Projects/Data/DataSource/API/AuthAPI.swift index c7b16bc7..e84925b1 100644 --- a/project/Projects/Data/DataSource/API/AuthAPI.swift +++ b/project/Projects/Data/DataSource/API/AuthAPI.swift @@ -168,10 +168,6 @@ extension AuthAPI: BaseAPI { } } - public var validationType: ValidationType { - .successCodes - } - public var task: Task { switch self { case .startPhoneNumberAuth: diff --git a/project/Projects/Data/DataSource/Service/BaseNetworkService.swift b/project/Projects/Data/DataSource/Service/BaseNetworkService.swift index 048ee8fa..2472155c 100644 --- a/project/Projects/Data/DataSource/Service/BaseNetworkService.swift +++ b/project/Projects/Data/DataSource/Service/BaseNetworkService.swift @@ -186,14 +186,51 @@ public extension BaseNetworkService { provider.rx .request(api) .catch { error in - if let moyaError = error as? MoyaError { + + let moyaError = error as! MoyaError + + // 재시도 실패 or 근본적인 에러(Ex 타임아웃, 네트워크 끊어짐) + if case let .underlying(error, response) = moyaError { - var response: Response? + // 리타리어 실패 + if let afError = error.asAFError { + + if case .requestRetryFailed = afError, let response { + + return .error( + HTTPResponseException(response: response) + ) + } + } - if case let .underlying(_, res) = moyaError { response = res } - if case let .statusCode(res) = moyaError { response = res } - if let response { return .error(HTTPResponseException(response: response)) } + // 근본적인 문제 + if let urlError = error as? URLError { + + var underlyingError: UnderLyingError! + + switch urlError.code { + case .notConnectedToInternet: + underlyingError = .internetNotConnected + case .networkConnectionLost: + underlyingError = .networkConnectionLost + case .timedOut: + underlyingError = .timeout + default: + underlyingError = .unHandledError + } + + return .error(underlyingError) + } } + + // HTTP통신 에러 + if case let .statusCode(response) = moyaError { + return .error( + HTTPResponseException(response: response) + ) + } + + // 엣지 케이스 return .error(error) } } diff --git a/project/Projects/Data/DataSource/Service/asd.swift b/project/Projects/Data/DataSource/Service/CrawlingPostService.swift similarity index 100% rename from project/Projects/Data/DataSource/Service/asd.swift rename to project/Projects/Data/DataSource/Service/CrawlingPostService.swift diff --git a/project/Projects/Data/Project.swift b/project/Projects/Data/Project.swift index 3745174c..68b28f0a 100644 --- a/project/Projects/Data/Project.swift +++ b/project/Projects/Data/Project.swift @@ -30,7 +30,8 @@ let project = Project( D.Data.DataSource, // ThirdParty - D.ThirdParty.RxSwift + D.ThirdParty.RxSwift, + D.ThirdParty.FirebaseRemoteConfig ], settings: .settings( base: ["ENABLE_TESTABILITY": "YES"] diff --git a/project/Projects/Domain/ConcreteUseCase/RecruitmentPost/DefualtRecruitmentPostUseCase.swift b/project/Projects/Domain/ConcreteUseCase/RecruitmentPost/DefualtRecruitmentPostUseCase.swift index 58969877..401df140 100644 --- a/project/Projects/Domain/ConcreteUseCase/RecruitmentPost/DefualtRecruitmentPostUseCase.swift +++ b/project/Projects/Domain/ConcreteUseCase/RecruitmentPost/DefualtRecruitmentPostUseCase.swift @@ -157,7 +157,8 @@ public class DefaultRecruitmentPostUseCase: RecruitmentPostUseCase { requestCnt: postCount ) case .thirdParty: - return .just(.failure(.undefinedError)) + // Presentation에서 에초에 호출하지 않음 + return .just(.failure(.undefinedCode)) } } diff --git a/project/Projects/Domain/Entity/Config/ForceUpdateInfo.swift b/project/Projects/Domain/Entity/Config/ForceUpdateInfo.swift new file mode 100644 index 00000000..00007e40 --- /dev/null +++ b/project/Projects/Domain/Entity/Config/ForceUpdateInfo.swift @@ -0,0 +1,14 @@ +// +// ForceUpdateInfo.swift +// Entity +// +// Created by choijunios on 9/13/24. +// + +import Foundation + +public struct ForceUpdateInfo: Decodable { + public let minVersion: String + public let marketUrl: String + public let noticeMsg: String +} diff --git a/project/Projects/Domain/Entity/Error/DomainError.swift b/project/Projects/Domain/Entity/Error/DomainError.swift index 210c3aea..55fa1c8f 100644 --- a/project/Projects/Domain/Entity/Error/DomainError.swift +++ b/project/Projects/Domain/Entity/Error/DomainError.swift @@ -7,7 +7,7 @@ import Foundation -public enum DomainError: Error { +public enum DomainError: Error, Equatable { // API case invalidParameter @@ -58,10 +58,8 @@ public enum DomainError: Error { // undefinedError case undefinedCode - case undefinedError - // Not Implemented - case notImplemented + case undelyingError(error: UnderLyingError) public init(code: String) { switch code { @@ -123,7 +121,7 @@ public enum DomainError: Error { self = .geoCodingFailure default: - self = .undefinedError + self = .undefinedCode } } @@ -199,11 +197,20 @@ public enum DomainError: Error { case .geoCodingFailure: return "입력된 주소로 지리 정보를 찾을 수 없습니다. 주소를 다시 확인해주세요." - case .undefinedCode, .undefinedError: - return "예기치 않은 오류가 발생했습니다. 잠시 후 다시 시도해주세요." - - case .notImplemented: - return "아직 개발되지 않은 기능입니다." + case .undefinedCode: + return "처리되지 않은 HTTP코드입니다." + + case .undelyingError(let underlyingError): + switch underlyingError { + case .internetNotConnected: + return "인터넷이 연결되지 않았습니다." + case .timeout: + return "요청시간을 초과했습니다." + case .networkConnectionLost: + return "연결상태가 변경됬습니다." + case .unHandledError: + return "알 수 없는 문제가 발생했습니다." + } } } } diff --git a/project/Projects/Domain/Entity/Error/NetworkError/UnderlyingError.swift b/project/Projects/Domain/Entity/Error/NetworkError/UnderlyingError.swift new file mode 100644 index 00000000..0b275cbf --- /dev/null +++ b/project/Projects/Domain/Entity/Error/NetworkError/UnderlyingError.swift @@ -0,0 +1,16 @@ +// +// Entity.swift +// ConcreteUseCase +// +// Created by choijunios on 9/13/24. +// + +import Foundation + +public enum UnderLyingError: Error { + + case internetNotConnected + case timeout + case networkConnectionLost + case unHandledError +} diff --git a/project/Projects/Domain/Entity/VO/AlertContentVO.swift b/project/Projects/Domain/Entity/VO/AlertContentVO.swift index ee5d4ee4..902f0b1a 100644 --- a/project/Projects/Domain/Entity/VO/AlertContentVO.swift +++ b/project/Projects/Domain/Entity/VO/AlertContentVO.swift @@ -11,11 +11,13 @@ public struct DefaultAlertContentVO { public let title: String public let message: String + public let dismissButtonLabelText: String public let onDismiss: (() -> ())? - public init(title: String, message: String, onDismiss: (() -> ())? = nil) { + public init(title: String, message: String, dismissButtonLabelText: String = "닫기", onDismiss: (() -> ())? = nil) { self.title = title self.message = message + self.dismissButtonLabelText = dismissButtonLabelText self.onDismiss = onDismiss } diff --git a/project/Projects/Domain/RepositoryInterface/RemotConfig/RemoteConfigRepository.swift b/project/Projects/Domain/RepositoryInterface/RemotConfig/RemoteConfigRepository.swift new file mode 100644 index 00000000..17886bc4 --- /dev/null +++ b/project/Projects/Domain/RepositoryInterface/RemotConfig/RemoteConfigRepository.swift @@ -0,0 +1,20 @@ +// +// RemoteConfigRepository.swift +// RepositoryInterface +// +// Created by choijunios on 9/13/24. +// + +import Foundation +import Entity + +import RxSwift + +public protocol RemoteConfigRepository: RepositoryBase { + + /// RemoteConfig를 fetch합니다. + func fetchRemoteConfig() -> Single> + + /// 강제업데이트 정보를 가져옵니다. + func getForceUpdateInfo() -> ForceUpdateInfo? +} diff --git a/project/Projects/Domain/UseCaseInterface/Auth/AuthInputValidationUseCase.swift b/project/Projects/Domain/UseCaseInterface/Auth/AuthInputValidationUseCase.swift index 2917d6c4..df830a1d 100644 --- a/project/Projects/Domain/UseCaseInterface/Auth/AuthInputValidationUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/Auth/AuthInputValidationUseCase.swift @@ -19,7 +19,7 @@ import Entity /// - #7. 아이디 중복확인 /// - #8. 패스워드 유효성 확인 -public protocol AuthInputValidationUseCase: UseCaseBase { +public protocol AuthInputValidationUseCase: BaseUseCase { // #1. /// 전화번호 인증 요청 diff --git a/project/Projects/Domain/UseCaseInterface/Auth/AuthUseCase.swift b/project/Projects/Domain/UseCaseInterface/Auth/AuthUseCase.swift index 78e31333..4f96b481 100644 --- a/project/Projects/Domain/UseCaseInterface/Auth/AuthUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/Auth/AuthUseCase.swift @@ -9,7 +9,7 @@ import Foundation import RxSwift import Entity -public protocol AuthUseCase: UseCaseBase { +public protocol AuthUseCase: BaseUseCase { /// 센터 회원가입 실행 diff --git a/project/Projects/Domain/UseCaseInterface/UseCaseBase.swift b/project/Projects/Domain/UseCaseInterface/BaseUseCase.swift similarity index 76% rename from project/Projects/Domain/UseCaseInterface/UseCaseBase.swift rename to project/Projects/Domain/UseCaseInterface/BaseUseCase.swift index 26ca6512..884fcca0 100644 --- a/project/Projects/Domain/UseCaseInterface/UseCaseBase.swift +++ b/project/Projects/Domain/UseCaseInterface/BaseUseCase.swift @@ -9,9 +9,9 @@ import Foundation import RxSwift import Entity -public protocol UseCaseBase: AnyObject { } +public protocol BaseUseCase: AnyObject { } -public extension UseCaseBase { +public extension BaseUseCase { /// Repository로 부터 전달받은 언어레벨의 에러를 도메인 특화 에러로 변경하고, error를 Result의 Failure로, 성공을 Success로 변경합니다. func convert(task: Single) -> Single> { @@ -28,7 +28,8 @@ public extension UseCaseBase { // MARK: InputValidationError private func toDomainError(error: Error) -> DomainError { - + + // 네트워크 에러 if let httpError = error as? HTTPResponseException { if let code = httpError.rawCode { @@ -49,6 +50,14 @@ public extension UseCaseBase { #endif } - return DomainError.undefinedError + // 네트워크 에러보다 근본적인 에러 + if let underlyingError = error as? UnderLyingError { + + let domainError: DomainError = .undelyingError(error: underlyingError) + + return domainError + } + + return DomainError.undelyingError(error: .unHandledError) } } diff --git a/project/Projects/Domain/UseCaseInterface/CenterCertificate/CenterCertificateUseCase.swift b/project/Projects/Domain/UseCaseInterface/CenterCertificate/CenterCertificateUseCase.swift index 4bd9c592..a82446e9 100644 --- a/project/Projects/Domain/UseCaseInterface/CenterCertificate/CenterCertificateUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/CenterCertificate/CenterCertificateUseCase.swift @@ -10,7 +10,7 @@ import Entity import RxSwift -public protocol CenterCertificateUseCase: UseCaseBase { +public protocol CenterCertificateUseCase: BaseUseCase { /// 센터 로그아웃 func signoutCenterAccount() -> Single> diff --git a/project/Projects/Domain/UseCaseInterface/RecruitmentPost/RecruitmentPostUseCase.swift b/project/Projects/Domain/UseCaseInterface/RecruitmentPost/RecruitmentPostUseCase.swift index a1689a7a..b4fce273 100644 --- a/project/Projects/Domain/UseCaseInterface/RecruitmentPost/RecruitmentPostUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/RecruitmentPost/RecruitmentPostUseCase.swift @@ -9,7 +9,7 @@ import Foundation import Entity import RxSwift -public protocol RecruitmentPostUseCase: UseCaseBase { +public protocol RecruitmentPostUseCase: BaseUseCase { // MARK: Center diff --git a/project/Projects/Domain/UseCaseInterface/Setting/SettingScreenUseCase .swift b/project/Projects/Domain/UseCaseInterface/Setting/SettingScreenUseCase .swift index f6bb3874..fa6e1e62 100644 --- a/project/Projects/Domain/UseCaseInterface/Setting/SettingScreenUseCase .swift +++ b/project/Projects/Domain/UseCaseInterface/Setting/SettingScreenUseCase .swift @@ -15,7 +15,7 @@ public enum NotificationApproveAction: Equatable { case error(message: String) } -public protocol SettingScreenUseCase: UseCaseBase { +public protocol SettingScreenUseCase: BaseUseCase { /// 현재 알람수신 동의 여부를 확인합니다. func checkPushNotificationApproved() -> Single diff --git a/project/Projects/Domain/UseCaseInterface/UserInfo/CenterProfileUseCase.swift b/project/Projects/Domain/UseCaseInterface/UserInfo/CenterProfileUseCase.swift index 1193d9bc..e958d502 100644 --- a/project/Projects/Domain/UseCaseInterface/UserInfo/CenterProfileUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/UserInfo/CenterProfileUseCase.swift @@ -16,7 +16,7 @@ import Entity /// 5. 센터 프로필 최초 등록 /// 6. 특정 센터의 프로필 불러오기 -public protocol CenterProfileUseCase: UseCaseBase { +public protocol CenterProfileUseCase: BaseUseCase { /// 1. 나의 센터/다른 센터 프로필 정보 조회 /// 6. 특정 센터의 프로필 불러오기 diff --git a/project/Projects/Domain/UseCaseInterface/UserInfo/WorkerProfileUseCase.swift b/project/Projects/Domain/UseCaseInterface/UserInfo/WorkerProfileUseCase.swift index f2669f37..9bce9761 100644 --- a/project/Projects/Domain/UseCaseInterface/UserInfo/WorkerProfileUseCase.swift +++ b/project/Projects/Domain/UseCaseInterface/UserInfo/WorkerProfileUseCase.swift @@ -15,7 +15,7 @@ import Entity /// 4. 나의(요보) 프로필 정보 업데이트(이미지, pre-signed-url-callback) /// 5. 특정 요양보호사의 프로필 불러오기 -public protocol WorkerProfileUseCase: UseCaseBase { +public protocol WorkerProfileUseCase: BaseUseCase { /// 1. 나의(요보) 프로필 정보 조회 /// 5. 특정 요양보호사의 프로필 불러오기 diff --git a/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift b/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift index 574354cf..8c9e1649 100644 --- a/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift +++ b/project/Projects/Presentation/DSKit/Sources/CommonUI/Alert /IdleBigAlertController.swift @@ -38,7 +38,8 @@ public class DefaultIdleAlertVM: IdleAlertViewModelable { description: String, acceptButtonLabelText: String, cancelButtonLabelText: String, - onAccepted: (() -> ())? = nil + onAccepted: (() -> ())? = nil, + onCanceled: (() -> ())? = nil ) { self.title = title self.description = description @@ -50,7 +51,9 @@ public class DefaultIdleAlertVM: IdleAlertViewModelable { acceptButtonClicked .map({ _ in onAccepted?() }) .asObservable(), - cancelButtonClicked.asObservable() + cancelButtonClicked + .map({ _ in onCanceled?() }) + .asObservable() ) .asDriver(onErrorDriveWith: .never()) } diff --git a/project/Projects/Presentation/Feature/Base/Project.swift b/project/Projects/Presentation/Feature/Base/Project.swift index c1d364b0..f0c7e4e2 100644 --- a/project/Projects/Presentation/Feature/Base/Project.swift +++ b/project/Projects/Presentation/Feature/Base/Project.swift @@ -39,6 +39,8 @@ let project = Project( D.ThirdParty.RxSwift, D.ThirdParty.RxCocoa, D.ThirdParty.NaverMapSDKForSPM, + D.ThirdParty.FirebaseCrashlytics, + D.ThirdParty.FirebaseAnalytics, ], settings: .settings( configurations: IdleConfiguration.presentationConfigurations diff --git a/project/Projects/Presentation/Feature/Base/Sources/View/View/RecruitmentPost/Worker/Detail/ViewModel/NativePostDetailForWorkerVM.swift b/project/Projects/Presentation/Feature/Base/Sources/View/View/RecruitmentPost/Worker/Detail/ViewModel/NativePostDetailForWorkerVM.swift index a5d44281..bb0b00ac 100644 --- a/project/Projects/Presentation/Feature/Base/Sources/View/View/RecruitmentPost/Worker/Detail/ViewModel/NativePostDetailForWorkerVM.swift +++ b/project/Projects/Presentation/Feature/Base/Sources/View/View/RecruitmentPost/Worker/Detail/ViewModel/NativePostDetailForWorkerVM.swift @@ -191,9 +191,9 @@ public class NativePostDetailForWorkerVM: BaseViewModel ,NativePostDetailForWork title: "공고에 지원하시겠어요?", description: "", acceptButtonLabelText: "지원하기", - cancelButtonLabelText: "취소하기") { + cancelButtonLabelText: "취소하기", onCanceled: { applyRequest.accept(()) - } + }) } .asDriver(onErrorDriveWith: .never()) diff --git a/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift b/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift index da7d3c69..03eb8e8b 100644 --- a/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift +++ b/project/Projects/Presentation/Feature/Base/Sources/View/ViewController/Base/BaseViewController.swift @@ -91,7 +91,7 @@ public extension BaseViewController { message: vo.message, preferredStyle: .alert ) - let close = UIAlertAction(title: "닫기", style: .default) { [weak self] _ in + let close = UIAlertAction(title: vo.dismissButtonLabelText, style: .default) { [weak self] _ in self?.alert(vo: vo) // dismiss diff --git a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift index 9f1d62de..98ade75d 100644 --- a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift +++ b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVC.swift @@ -8,11 +8,12 @@ import UIKit import BaseFeature import PresentationCore -import RxCocoa -import RxSwift import Entity import DSKit +import RxCocoa +import RxSwift + public class InitialScreenVC: BaseViewController { // Init @@ -44,8 +45,8 @@ public class InitialScreenVC: BaseViewController { super.bind(viewModel: viewModel) - self.rx.viewWillAppear - .map { _ in () } + self.rx.viewDidAppear + .map({ _ in }) .bind(to: viewModel.viewWillAppear) .disposed(by: disposeBag) } diff --git a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift index 3daad6b3..a98b924e 100644 --- a/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift +++ b/project/Projects/Presentation/Feature/Root/Sources/Screen/Common/InitialScreen/InitialScreenVM.swift @@ -5,14 +5,20 @@ // Created by choijunios on 8/25/24. // -import RxSwift -import RxCocoa -import Foundation +import UIKit +import Network + import PresentationCore import UseCaseInterface import RepositoryInterface import BaseFeature import Entity +import DSKit + +import RxSwift +import RxCocoa +import FirebaseCrashlytics + public class InitialScreenVM: BaseViewModel { @@ -26,13 +32,20 @@ public class InitialScreenVM: BaseViewModel { let workerProfileUseCase: WorkerProfileUseCase let centerProfileUseCase: CenterProfileUseCase let userInfoLocalRepository: UserInfoLocalRepository + let remoteConfigRepository: RemoteConfigRepository + + // network monitoring + private let networkMonitor: NWPathMonitor = .init() + private let networkMonitoringQueue = DispatchQueue.global(qos: .background) + private let networtIsAvailablePublisher: PublishSubject = .init() public init( coordinator: RootCoorinatable?, authUseCase: AuthUseCase, workerProfileUseCase: WorkerProfileUseCase, centerProfileUseCase: CenterProfileUseCase, - userInfoLocalRepository: UserInfoLocalRepository + userInfoLocalRepository: UserInfoLocalRepository, + remoteConfigRepository: RemoteConfigRepository ) { self.coordinator = coordinator @@ -40,22 +53,127 @@ public class InitialScreenVM: BaseViewModel { self.workerProfileUseCase = workerProfileUseCase self.centerProfileUseCase = centerProfileUseCase self.userInfoLocalRepository = userInfoLocalRepository + self.remoteConfigRepository = remoteConfigRepository super.init() - // MARK: 로그아웃, 회원탈퇴시 - NotificationCenter.default.rx.notification(.popToInitialVC) - .observe(on: MainScheduler.instance) + // MARK: 네트워크 모니터링 시작 + let networkConnected: ReplaySubject = .create(bufferSize: 1) + + // 최초 1회 네트워크 연결이벤트 전송 + networtIsAvailablePublisher + .filter { $0 } + .take(1) + .map { _ in } + .bind(to: networkConnected) + .disposed(by: disposeBag) + + // 네트워크가 연결되지 않은 경우 재시도 하며, 재시도 실패시 같은 플로우 반복 + networtIsAvailablePublisher.filter { !$0 } .subscribe(onNext: { [weak self] _ in - guard let self else { return } + let alertVO = DefaultAlertContentVO( + title: "인터넷 연결이 불안정해요", + message: "Wi-Fi 또는 셀룰러 데이터 연결을 확인한 후 다시 시도해 주세요.", + dismissButtonLabelText: "다시 시도하기") { [weak self] in + + DispatchQueue.main.asyncAfter(deadline: .now()+1) { [weak self] in + guard let self else { return } + + if self.networkMonitor.currentPath.status == .unsatisfied { + + self.networtIsAvailablePublisher.onNext(false) + } + } + } - self.coordinator?.popToRoot() + // 네트워크 연결되지 않음 + self?.alert.onNext(alertVO) }) .disposed(by: disposeBag) + startNeworkMonitoring() + + + // MARK: 강제업데이트 확인 + // 네트워크 확인 -> 강제업데이트 확인 + let needsForceUpdate = networkConnected + .flatMap { [remoteConfigRepository] _ in + remoteConfigRepository.fetchRemoteConfig() + } + .compactMap { $0.value } + .map { [remoteConfigRepository] isConfigFetched in + + if !isConfigFetched { + Crashlytics.crashlytics().log("Remote Config fetch실패") + } + + guard let config = remoteConfigRepository.getForceUpdateInfo() else { + // ‼️ Config로딩 불가시 크래쉬 + Crashlytics.crashlytics().log("Remote Config획득 실패") + fatalError("Remote Config fetching에러") + } + + return config + } + .map { info in + + let minAppVersion = info.minVersion + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String + + printIfDebug("앱 버전: \(appVersion) 최소앱버전: \(minAppVersion)") + + return minAppVersion > appVersion + } + .share() + + // 강제업데이트 필요 + needsForceUpdate + .filter { $0 } + .subscribe(onNext: { + [weak self] _ in + + guard let self else { return } + + // 네트워크 연결되지 않음 + let object = IdleAlertObject() + .setTitle("최신 버전의 앱이 있어요") + .setDescription("유저분들의 의견을 반영해 앱을 더 발전시켰어요.\n보다 좋은 서비스를 만나기 위해, 업데이트해주세요.") + .setAcceptButtonLabelText("앱 종료") + .setCancelButtonLabelText("앱 업데이트") + + object + .cancelButtonClicked + .subscribe(onNext: { [weak self] in + self?.openAppStoreForUpdate() + }) + .disposed(by: disposeBag) + + object + .acceptButtonClicked + .subscribe(onNext: { + exit(0) + }) + .disposed(by: disposeBag) + + alertObject.onNext(object) + }) + .disposed(by: disposeBag) - viewWillAppear + // 강제업데이트 필요하지 않음 + let forceUpdateChecked = needsForceUpdate.filter { !$0 } + + // MARK: 유저별 플로우 시작 + // 네트워크 연결확인 -> 강제업데이트 확인 -> 유저별 플로우 시작 + Observable + .combineLatest( + // 강제업데이트 확인 완료 + forceUpdateChecked, + + // viewWillAppear + viewWillAppear + ) .subscribe(onNext: { [weak self] _ in guard let self else { exit(0) } @@ -76,6 +194,18 @@ public class InitialScreenVM: BaseViewModel { }) .disposed(by: disposeBag) + + + // MARK: 로그아웃, 회원탈퇴시 + NotificationCenter.default.rx.notification(.popToInitialVC) + .observe(on: MainScheduler.instance) + .subscribe(onNext: { [weak self] _ in + + guard let self else { return } + + self.coordinator?.popToRoot() + }) + .disposed(by: disposeBag) } func workerInitialFlow() { @@ -127,6 +257,37 @@ public class InitialScreenVM: BaseViewModel { .disposed(by: disposeBag) } + deinit { + networkMonitor.cancel() + } + + func startNeworkMonitoring() { + + networkMonitor.pathUpdateHandler = { [weak self] path in + + DispatchQueue.main.async { + self?.checkNetworkStatusAndPublish(status: path.status, delay: 0) + } + } + + networkMonitor.start(queue: networkMonitoringQueue) + } + + func checkNetworkStatusAndPublish(status: NWPath.Status, delay: Int) { + + switch status { + case .requiresConnection, .satisfied: + // requiresConnection는 일반적으로 즉시 연결이 가능한 상태 + networtIsAvailablePublisher.onNext(true) + networtIsAvailablePublisher.onCompleted() + networkMonitor.cancel() + return + default: + networtIsAvailablePublisher.onNext(false) + return + } + } + func centerInitialFlow() { // #1. 센터 상태를 확인함과 동시에 토큰 유효성 확인 @@ -144,7 +305,7 @@ public class InitialScreenVM: BaseViewModel { guard let self else { return } switch error { - case .tokenExpiredException: + case .tokenExpiredException, .tokenNotFound: // 토큰이 만료된 경우 coordinator?.auth() default: @@ -227,5 +388,18 @@ public class InitialScreenVM: BaseViewModel { }) .disposed(by: disposeBag) } - + + func checkForceUpdate() { + + + } + + /// 앱스토에에서 해당앱을 엽니다. + func openAppStoreForUpdate() { + let url = "itms-apps://itunes.apple.com/app/6670529341"; + if let url = URL(string: url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) + } + } } + diff --git a/project/Tuist/Package.swift b/project/Tuist/Package.swift index c57a0c65..0b515062 100644 --- a/project/Tuist/Package.swift +++ b/project/Tuist/Package.swift @@ -41,6 +41,8 @@ let package = Package( // Naver map .package(url: "https://github.com/J0onYEong/NaverMapSDKForSPM.git", from: "1.0.0"), // KingFisher - .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.12.0") + .package(url: "https://github.com/onevcat/Kingfisher.git", from: "7.12.0"), + // Firebase + .package(url: "https://github.com/firebase/firebase-ios-sdk.git", from: "11.2.0"), ] )