diff --git a/Plugins/EnvironmentPlugin/ProjectDescriptionHelpers/InfoPlist.swift b/Plugins/EnvironmentPlugin/ProjectDescriptionHelpers/InfoPlist.swift index d82177a8..b74a57b9 100644 --- a/Plugins/EnvironmentPlugin/ProjectDescriptionHelpers/InfoPlist.swift +++ b/Plugins/EnvironmentPlugin/ProjectDescriptionHelpers/InfoPlist.swift @@ -50,7 +50,8 @@ public extension [String: Plist.Value] { "NMFClientId": "$(NAVERMAP_CLIENT_ID)", "TERMS_OF_PRIVACY_URL": "$(TERMS_OF_PRIVACY_URL)", "LOCATION_PRIVACY_URL": "$(LOCATION_PRIVACY_URL)", - "INQURY_URL": "$(INQURY_URL)" + "INQURY_URL": "$(INQURY_URL)", + "APPSTORE_ID": "$(APPSTORE_ID)", ] static let additionalInfoPlist: Self = [ diff --git a/Projects/App/Sources/AppStoreCheck.swift b/Projects/App/Sources/AppStoreCheck.swift new file mode 100644 index 00000000..d7e9048d --- /dev/null +++ b/Projects/App/Sources/AppStoreCheck.swift @@ -0,0 +1,90 @@ +// +// AppStoreCheck.swift +// App +// +// Created by Jisoo Ham on 5/26/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import UIKit + +import Core +import Domain +import NetworkService + +import RxSwift + +enum AppStoreError: Error { + case invalidURL + case noData + case parsingError + case networkError(Error) +} + +public final class DefaultAppStoreCheck { + static let shared = DefaultAppStoreCheck() + + private let appstoreID: String? + + public let appStoreURLString + = "itms-apps://itunes.apple.com/app/apple-store/" + + private init() { + appstoreID = Bundle.main.object(forInfoDictionaryKey: "APPSTORE_ID") + as? String + } + + public func latestVersion() -> Single { + return Single.create { [weak self] single in + guard let self = self else { + single(.failure(AppStoreError.invalidURL)) + return Disposables.create() + } + Task { + do { + guard let appstoreID = self.appstoreID, + let urlRequest = AppStoreEndPoint( + appStoreID: appstoreID).toURLRequest + else { + throw AppStoreError.invalidURL + } + + let (data, _) = try await URLSession + .shared.data(for: urlRequest) + + let json = try JSONSerialization.jsonObject( + with: data, + options: .allowFragments + ) as? [String: Any] + + guard let results = json?["results"] as? [[String: Any]], + let appStoreVersion = results.first?["version"] + as? String + else { + throw AppStoreError.parsingError + } + + single(.success(appStoreVersion)) + + } catch let error as AppStoreError { + single(.failure(error)) + } catch { + single(.failure(AppStoreError.networkError(error))) + } + } + return Disposables.create() + } + } + /// URL을 통해 앱스토어 오픈 + public func openAppStore() { + guard let appstoreID, + let url = URL( + string: appStoreURLString + appstoreID + ) + else { return } + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } +} diff --git a/Projects/App/Sources/SceneDelegate.swift b/Projects/App/Sources/SceneDelegate.swift index 2d12b3d9..8d4d67e2 100644 --- a/Projects/App/Sources/SceneDelegate.swift +++ b/Projects/App/Sources/SceneDelegate.swift @@ -18,9 +18,11 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? var appCoordinator: AppCoordinator? var deeplinkHandler: DeeplinkHandler? + + let disposeBag = DisposeBag() func scene( - _ scene: UIScene, + _ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions ) { @@ -37,6 +39,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { ) appCoordinator?.start() window?.makeKeyAndVisible() + checkAndUpdateIfNeeded() deeplinkHandler = .init(appCoordinator: appCoordinator) if let url = connectionOptions.urlContexts.first?.url { deeplinkHandler?.handleUrl(url: url) @@ -52,7 +55,9 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillResignActive(_ scene: UIScene) { } + /// 앱이 Foreground로 전환될때 실행될 함수 func sceneWillEnterForeground(_ scene: UIScene) { + checkAndUpdateIfNeeded() } func sceneDidEnterBackground(_ scene: UIScene) { @@ -66,5 +71,50 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { deeplinkHandler?.handleUrl(url: url) } } + + private func checkAndUpdateIfNeeded() { + DefaultAppStoreCheck.shared.latestVersion() + .observe(on: MainScheduler.instance) + .subscribe(onSuccess: { version in + let splitMarketingVersion = version.split(separator: ".") + .map { $0 } + let splitCurrentVersion = String.getCurrentVersion() + .split(separator: ".") + .map { $0 } + + if splitCurrentVersion.count > 0 && + splitMarketingVersion.count > 0 { + // Major 버전만을 비교 + if splitCurrentVersion[0] < splitMarketingVersion[0] { + self.showUpdateAlert(version: version) + } else { + print("현재 최신 버전입니다.") + } + } + }, onFailure: { err in + print(err, #function) + }) + .disposed(by: disposeBag) + } + + private func showUpdateAlert(version: String) { + let alert = UIAlertController( + title: "업데이트 알림", + message: "더 나은 서비스를 위해 업데이트 되었어요 ! 업데이트 해주세요.", + preferredStyle: .alert + ) + + let alertAction = UIAlertAction( + title: "업데이트", + style: .default + ) { _ in + DefaultAppStoreCheck.shared.openAppStore() + } + + alert.addAction(alertAction) + DispatchQueue.main.async { + self.window?.rootViewController?.present(alert, animated: true) + } + } } diff --git a/Projects/Core/Sources/Extension/String+.swift b/Projects/Core/Sources/Extension/String+.swift index 4be1e8cc..5da76d19 100644 --- a/Projects/Core/Sources/Extension/String+.swift +++ b/Projects/Core/Sources/Extension/String+.swift @@ -34,10 +34,11 @@ public extension String { return serverKey } + /// 프로젝트 버전 static func getCurrentVersion() -> String { guard let dictionary = Bundle.main.infoDictionary, let version = dictionary["CFBundleShortVersionString"] as? String - else { return "" } + else { return "1" } return version } diff --git a/Projects/Data/Sources/DTO/AppInfoDTO.swift b/Projects/Data/Sources/DTO/AppInfoDTO.swift new file mode 100644 index 00000000..123fb836 --- /dev/null +++ b/Projects/Data/Sources/DTO/AppInfoDTO.swift @@ -0,0 +1,80 @@ +// +// AppInfoDTO.swift +// Data +// +// Created by Jisoo HAM on 8/1/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import Foundation + +import Domain + +public struct AppInfoDTO: Decodable { + let resultCount: Int + let results: [AppDetailDTO] +} + +extension AppInfoDTO { + var toDomain: AppInfoResponse { + let results = results.map { + AppDetailResponse( + releaseNotes: $0.releaseNotes, + releaseDate: $0.releaseDate, + version: $0.version + ) + } + return AppInfoResponse.init( + resultCount: resultCount, + results: results + ) + } +} + +extension AppInfoDTO { + struct AppDetailDTO: Decodable { + let advisories: [String] + let appletvScreenshotUrls: [String] + let artistId: Int + let artistName: String + let artistViewUrl: String + let artworkUrl100: String + let artworkUrl512: String + let artworkUrl60: String + let averageUserRating: Double + let averageUserRatingForCurrentVersion: Double + let bundleId: String + let contentAdvisoryRating: String + let currency: String + let currentVersionReleaseDate: String + let description: String + let features: [String] + let fileSizeBytes: Int + let formattedPrice: String + let genreIds: [String] + let genres: [String] + let ipadScreenshotUrls: [String] + let isGameCenterEnabled: Bool + let isVppDeviceBasedLicensingEnabled: Bool + let kind: String + let languageCodesISO2A: [String] + let minimumOsVersion: String + let price: Double + let primaryGenreId: Int + let primaryGenreName: String + let releaseDate: String + let releaseNotes: String + let screenshotUrls: [String] + let sellerName: String + let supportedDevices: [String] + let trackCensoredName: String + let trackContentRating: String + let trackId: Int + let trackName: String + let trackViewUrl: String + let userRatingCount: Int + let userRatingCountForCurrentVersion: Int + let version: String + let wrapperType: String + } +} diff --git a/Projects/Data/Sources/Repository/DefaultAppStoreRepository.swift b/Projects/Data/Sources/Repository/DefaultAppStoreRepository.swift new file mode 100644 index 00000000..2c624a1c --- /dev/null +++ b/Projects/Data/Sources/Repository/DefaultAppStoreRepository.swift @@ -0,0 +1,77 @@ +// +// DefaultAppStoreRepository.swift +// Data +// +// Created by Jisoo HAM on 8/1/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import UIKit + +import Domain +import NetworkService + +import RxSwift + +enum AppStoreError: Error, LocalizedError { + case invalidAppStoreId + case invalidURL + case noData + case parsingError + case networkError(Error) + + var errorDescription: String? { + switch self { + case .invalidAppStoreId: + "AppStore Id 잘못됨" + case .invalidURL: + "AppStore URL 잘못됨" + case .noData: + "Data 잘못됨" + case .parsingError: + "데이터 parsing 잘못됨" + case .networkError(let error): + "\(error) 네트워크 에러" + } + } +} + +public final class DefaultAppStoreRepository: AppstoreRepository { + private let networkService: NetworkService + private let appStoreId = Bundle.main + .object(forInfoDictionaryKey: "APPSTORE_ID") as? String + + public let appStoreURLString + = "itms-apps://itunes.apple.com/app/apple-store/" + + public init(networkService: NetworkService) { + self.networkService = networkService + } + + public func latestVersion() -> Observable { + guard let appStoreId else { + return Observable.error(AppStoreError.invalidAppStoreId) + } + let data = networkService.request( + endPoint: AppStoreEndPoint(appStoreID: appStoreId) + ) + .decode( + type: AppInfoDTO.self, + decoder: JSONDecoder() + ) + .compactMap { $0.toDomain } + return data + } + + public func openAppStore() { + guard let appStoreId, + let url = URL( + string: appStoreURLString + appStoreId + ) + else { return } + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } +} diff --git a/Projects/Domain/Sources/Entity/Response/AppInfoResponse.swift b/Projects/Domain/Sources/Entity/Response/AppInfoResponse.swift new file mode 100644 index 00000000..aaf6a84a --- /dev/null +++ b/Projects/Domain/Sources/Entity/Response/AppInfoResponse.swift @@ -0,0 +1,31 @@ +// +// AppInfoResponse.swift +// Domain +// +// Created by Jisoo HAM on 8/1/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import Foundation + +public struct AppInfoResponse { + let resultCount: Int + let results: [AppDetailResponse] + + public init(resultCount: Int, results: [AppDetailResponse]) { + self.resultCount = resultCount + self.results = results + } +} + +public struct AppDetailResponse: Decodable { + let releaseNotes: String + let releaseDate: String + let version: String + + public init(releaseNotes: String, releaseDate: String, version: String) { + self.releaseNotes = releaseNotes + self.releaseDate = releaseDate + self.version = version + } +} diff --git a/Projects/Domain/Sources/RepositoryInterface/AppstoreRepository.swift b/Projects/Domain/Sources/RepositoryInterface/AppstoreRepository.swift new file mode 100644 index 00000000..f4348fc1 --- /dev/null +++ b/Projects/Domain/Sources/RepositoryInterface/AppstoreRepository.swift @@ -0,0 +1,15 @@ +// +// AppstoreRepository.swift +// Domain +// +// Created by Jisoo HAM on 8/1/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import Foundation + +import RxSwift + +public protocol AppstoreRepository { + func latestVersion() -> Observable +} diff --git a/Projects/NetworkService/Sources/EndPoint/AppStoreEndPoint.swift b/Projects/NetworkService/Sources/EndPoint/AppStoreEndPoint.swift new file mode 100644 index 00000000..35a78f27 --- /dev/null +++ b/Projects/NetworkService/Sources/EndPoint/AppStoreEndPoint.swift @@ -0,0 +1,45 @@ +// +// AppStoreEndPoint.swift +// NetworkService +// +// Created by Jisoo HAM on 7/31/24. +// Copyright © 2024 Pepsi-Club. All rights reserved. +// + +import Foundation + +public struct AppStoreEndPoint: EndPoint { + private let appStoreID: String + + public var scheme: Scheme { + return .https + } + public var host: String { + return "itunes.apple.com" + } + public var port: String { + "" + } + public var path: String { + return "/lookup" + } + public var query: [String: String] { + return [ + "id": appStoreID, + "country": "kr" + ] + } + public var header: [String: String] { + return [:] + } + public var body: [String: Any] { + return [:] + } + public var method: HTTPMethod { + return .get + } + + public init(appStoreID: String) { + self.appStoreID = appStoreID + } +}