From ff98208c811a069e4013b5d89fd0692502997ace Mon Sep 17 00:00:00 2001 From: MUKER-WON Date: Sat, 12 Jul 2025 11:56:54 +0900 Subject: [PATCH] =?UTF-8?q?[Feat]=20FileManagerService=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20github=20json=20=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Projects/App/Project.swift | 1 + .../App/Sources/AppDelegate+Register.swift | 12 +- .../Sources/Coordinator/AppCoordinator.swift | 23 ++- Projects/Core/Sources/Extension/String+.swift | 9 ++ Projects/Data/Project.swift | 1 + .../DefaultBusStationVersionRepository.swift | 33 ++++ .../DefaultGithubFileDownloadRepository.swift | 38 +++++ .../Sources/Entity/BusStationVersion.swift | 5 + .../BusStationVersionRepository.swift | 14 ++ .../GithubFileDownloadRepository.swift | 12 ++ .../UseCase/UpdateBusStationListUseCase.swift | 60 ++++++++ Projects/FileManagerService/Project.swift | 9 ++ .../Sources/DefaultFileManagerService.swift | 141 ++++++++++++++++++ .../Sources/FileManagerService.swift | 27 ++++ .../Sources/FileManagerServiceError.swift | 18 +++ .../EndPoint/GithubFileDownloadEndPoint.swift | 25 ++++ .../InfoPlist/SecretInfoPlist.swift | 1 + .../Module/Local/FileManagerService.swift | 16 ++ 18 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 Projects/Data/Sources/Repository/DefaultBusStationVersionRepository.swift create mode 100644 Projects/Data/Sources/Repository/DefaultGithubFileDownloadRepository.swift create mode 100644 Projects/Domain/Sources/Entity/BusStationVersion.swift create mode 100644 Projects/Domain/Sources/RepositoryInterface/BusStationVersionRepository.swift create mode 100644 Projects/Domain/Sources/RepositoryInterface/GithubFileDownloadRepository.swift create mode 100644 Projects/Domain/Sources/UseCase/UpdateBusStationListUseCase.swift create mode 100644 Projects/FileManagerService/Project.swift create mode 100644 Projects/FileManagerService/Sources/DefaultFileManagerService.swift create mode 100644 Projects/FileManagerService/Sources/FileManagerService.swift create mode 100644 Projects/FileManagerService/Sources/FileManagerServiceError.swift create mode 100644 Projects/NetworkService/Sources/EndPoint/GithubFileDownloadEndPoint.swift create mode 100644 Tuist/ProjectDescriptionHelpers/Module/Local/FileManagerService.swift diff --git a/Projects/App/Project.swift b/Projects/App/Project.swift index 5a68de13..0cc14886 100644 --- a/Projects/App/Project.swift +++ b/Projects/App/Project.swift @@ -6,6 +6,7 @@ let project = Project(name: "App") { MainFeature() Data() FirebaseModule() + FileManagerService() SwiftLintScript() UIKitInfoPlist() AppInfoPlist(displayName: .displayName, marketingVersion: .marketingVersion, buildVersion: .buildVersion) diff --git a/Projects/App/Sources/AppDelegate+Register.swift b/Projects/App/Sources/AppDelegate+Register.swift index e2917c44..32b2fa3a 100644 --- a/Projects/App/Sources/AppDelegate+Register.swift +++ b/Projects/App/Sources/AppDelegate+Register.swift @@ -14,25 +14,32 @@ import Data import Domain import NetworkService import FirebaseModule +import FileManagerService extension AppDelegate { func registerDependencies() { let firebaseLogger = FirebaseLoggerImpl() DIContainer.setLogger(firebaseLogger) + // MARK: Service DIContainer.register(type: ForceUpdateService.self, DefaultForceUpdateService()) DIContainer.register(type: CoreDataService.self, DefaultCoreDataService()) DIContainer.register(type: NetworkService.self, DefaultNetworkService()) DIContainer.register(type: LocationService.self, DefaultLocationService()) + DIContainer.register(type: FileManagerService.self, DefaultFileManagerService()) + DIContainer.register(type: LocalNotificationService.self, DefaultLocalNotificationService()) + DIContainer.register(type: RegularAlarmEditingService.self, DefaultRegularAlarmEditingService()) + // MARK: Repository DIContainer.register(type: FavoritesRepository.self, DefaultFavoritesRepository()) DIContainer.register(type: BusStopArrivalInfoRepository.self, DefaultBusStopArrivalInfoRepository()) DIContainer.register(type: StationListRepository.self, DefaultStationListRepository()) DIContainer.register(type: RegularAlarmRepository.self, DefaultRegularAlarmRepository()) - DIContainer.register(type: LocalNotificationService.self, DefaultLocalNotificationService()) - DIContainer.register(type: RegularAlarmEditingService.self, DefaultRegularAlarmEditingService()) DIContainer.register(type: VersionCheckRepository.self, DefaultVersionCheckRepository()) + DIContainer.register(type: BusStationVersionRepository.self, DefaultBusStationVersionRepository()) + DIContainer.register(type: GithubFileDownloadRepository.self, DefaultGithubFileDownloadRepository()) + // MARK: UseCase DIContainer.register(type: FavoritesUseCase.self, DefaultFavoritesUseCase()) DIContainer.register(type: RegularAlarmUseCase.self, DefaultRegularAlarmUseCase()) DIContainer.register(type: AddRegularAlarmUseCase.self, DefaultAddRegularAlarmUseCase()) @@ -41,5 +48,6 @@ extension AppDelegate { DIContainer.register(type: NearMapUseCase.self, DefaultNearMapUseCase()) DIContainer.register(type: FirebaseLogger.self, firebaseLogger) DIContainer.register(type: VersionCheckUseCase.self, DefaultVersionCheckUseCase()) + DIContainer.register(type: UpdateBusStationListUseCase.self, DefaultUpdateBusStationListUseCase()) } } diff --git a/Projects/App/Sources/Coordinator/AppCoordinator.swift b/Projects/App/Sources/Coordinator/AppCoordinator.swift index ee5ae638..2ec5f05a 100644 --- a/Projects/App/Sources/Coordinator/AppCoordinator.swift +++ b/Projects/App/Sources/Coordinator/AppCoordinator.swift @@ -8,9 +8,12 @@ import UIKit +import Core +import Domain import FeatureDependency import MainFeature import BusStopFeature +import RxSwift final class AppCoordinator: Coordinator { var parent: Coordinator? @@ -18,14 +21,17 @@ final class AppCoordinator: Coordinator { var navigationController: UINavigationController public var coordinatorType: CoordinatorType = .app private let coordinatorProvider = DefaultCoordinatorProvider() + private let disposeBag = DisposeBag() init(navigationController: UINavigationController) { self.navigationController = navigationController } func start() { + checkAndDownloadBusStationList() + let tabBarCoordinator = TabBarCoordinator( - navigationController: navigationController, + navigationController: navigationController, coordinatorProvider: coordinatorProvider ) childs.append(tabBarCoordinator) @@ -43,4 +49,19 @@ final class AppCoordinator: Coordinator { childs.append(busStopCoordinator) busStopCoordinator.start() } + + private func checkAndDownloadBusStationList() { + @Injected var useCase: UpdateBusStationListUseCase + + useCase.execute() + .subscribe( + onError: { error in + print("🚏❌ bus_station_list.json μ—…λ°μ΄νŠΈ μ‹€νŒ¨: \(error)") + }, + onCompleted: { + print("πŸšβœ… bus_station_list.json μ—…λ°μ΄νŠΈ 확인 및 처리 μ™„λ£Œ") + } + ) + .disposed(by: disposeBag) + } } diff --git a/Projects/Core/Sources/Extension/String+.swift b/Projects/Core/Sources/Extension/String+.swift index e4162762..b3a2c6cc 100644 --- a/Projects/Core/Sources/Extension/String+.swift +++ b/Projects/Core/Sources/Extension/String+.swift @@ -34,6 +34,15 @@ public extension String { return serverKey } + static var githubAccessToken: Self { + guard let any = Bundle.main.object(forInfoDictionaryKey: "GITHUB_ACCESS_TOKEN"), + let githubAccessToken = any as? String + else { + return "" + } + return githubAccessToken + } + /// domain url static var domainURL: Self { guard let any = Bundle.main.object( diff --git a/Projects/Data/Project.swift b/Projects/Data/Project.swift index ef6f50a4..93089132 100644 --- a/Projects/Data/Project.swift +++ b/Projects/Data/Project.swift @@ -7,6 +7,7 @@ let project = Project(name: "Data") { NetworkService() CoreDataService() FirebaseInterface() + FileManagerService() FrameworkInfoPlist(marketingVersion: .marketingVersion) } } diff --git a/Projects/Data/Sources/Repository/DefaultBusStationVersionRepository.swift b/Projects/Data/Sources/Repository/DefaultBusStationVersionRepository.swift new file mode 100644 index 00000000..02d9bd86 --- /dev/null +++ b/Projects/Data/Sources/Repository/DefaultBusStationVersionRepository.swift @@ -0,0 +1,33 @@ +import Foundation + +import Domain +import NetworkService +import Core + +import RxSwift + +public final class DefaultBusStationVersionRepository: BusStationVersionRepository { + @Injected private var networkService: NetworkService + + @UserDefaultsWrapper(key: "busStationDataVersion", defaultValue: nil) + private var localVersion: String? + + public init() { } + + public func fetchRemoteVersion() -> Observable { + let endPoint = GithubFileDownloadEndPoint( + repo: "BusStationData", + filePath: "bus_station_version.json" + ) + return networkService.request(endPoint: endPoint) + .decode(type: BusStationVersion.self, decoder: JSONDecoder()) + } + + public func fetchLocalVersion() -> String? { + return localVersion + } + + public func save(version: String) { + localVersion = version + } +} diff --git a/Projects/Data/Sources/Repository/DefaultGithubFileDownloadRepository.swift b/Projects/Data/Sources/Repository/DefaultGithubFileDownloadRepository.swift new file mode 100644 index 00000000..358b5dc0 --- /dev/null +++ b/Projects/Data/Sources/Repository/DefaultGithubFileDownloadRepository.swift @@ -0,0 +1,38 @@ +import Foundation + +import Domain +import FileManagerService +import NetworkService +import Core + +import RxSwift + +public final class DefaultGithubFileDownloadRepository: GithubFileDownloadRepository { + @Injected private var networkService: NetworkService + @Injected private var fileManagerService: FileManagerService + + public init() { } + + public func downloadFile( + repo: String, + filePath: String, + directoryName: String, + fileName: String + ) -> Observable { + let endPoint = GithubFileDownloadEndPoint( + repo: repo, + filePath: filePath + ) + return networkService.request(endPoint: endPoint) + .flatMap { [weak self] data -> Observable in + guard let self = self else { + return .error(RxError.unknown) // Or a custom error + } + return self.fileManagerService.save( + data: data, + directoryName: directoryName, + fileName: fileName + ) + } + } +} diff --git a/Projects/Domain/Sources/Entity/BusStationVersion.swift b/Projects/Domain/Sources/Entity/BusStationVersion.swift new file mode 100644 index 00000000..5ac20278 --- /dev/null +++ b/Projects/Domain/Sources/Entity/BusStationVersion.swift @@ -0,0 +1,5 @@ +import Foundation + +public struct BusStationVersion: Decodable { + public let busStationVersion: String +} diff --git a/Projects/Domain/Sources/RepositoryInterface/BusStationVersionRepository.swift b/Projects/Domain/Sources/RepositoryInterface/BusStationVersionRepository.swift new file mode 100644 index 00000000..e7e8ce51 --- /dev/null +++ b/Projects/Domain/Sources/RepositoryInterface/BusStationVersionRepository.swift @@ -0,0 +1,14 @@ +import Foundation + +import RxSwift + +public protocol BusStationVersionRepository { + /// 원격 μ €μž₯μ†Œμ—μ„œ μ΅œμ‹  λ²„μŠ€ μ •λ₯˜μž₯ λ°μ΄ν„°μ˜ 버전 정보λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€. + func fetchRemoteVersion() -> Observable + + /// λ‘œμ»¬μ— μ €μž₯된 λ²„μŠ€ μ •λ₯˜μž₯ λ°μ΄ν„°μ˜ 버전 정보λ₯Ό κ°€μ Έμ˜΅λ‹ˆλ‹€. + func fetchLocalVersion() -> String? + + /// λ‘œμ»¬μ— μƒˆλ‘œμš΄ λ²„μŠ€ μ •λ₯˜μž₯ λ°μ΄ν„°μ˜ 버전 정보λ₯Ό μ €μž₯ν•©λ‹ˆλ‹€. + func save(version: String) +} diff --git a/Projects/Domain/Sources/RepositoryInterface/GithubFileDownloadRepository.swift b/Projects/Domain/Sources/RepositoryInterface/GithubFileDownloadRepository.swift new file mode 100644 index 00000000..9e3f6762 --- /dev/null +++ b/Projects/Domain/Sources/RepositoryInterface/GithubFileDownloadRepository.swift @@ -0,0 +1,12 @@ +import Foundation + +import RxSwift + +public protocol GithubFileDownloadRepository { + func downloadFile( + repo: String, + filePath: String, + directoryName: String, + fileName: String + ) -> Observable +} diff --git a/Projects/Domain/Sources/UseCase/UpdateBusStationListUseCase.swift b/Projects/Domain/Sources/UseCase/UpdateBusStationListUseCase.swift new file mode 100644 index 00000000..4b637c9f --- /dev/null +++ b/Projects/Domain/Sources/UseCase/UpdateBusStationListUseCase.swift @@ -0,0 +1,60 @@ +import Foundation +import Core +import RxSwift + +public protocol UpdateBusStationListUseCase { + func execute() -> Observable +} + +public final class DefaultUpdateBusStationListUseCase: UpdateBusStationListUseCase { + @Injected private var versionRepository: BusStationVersionRepository + @Injected private var fileDownloadRepository: GithubFileDownloadRepository + + public init() { } + + public func execute() -> Observable { + return versionRepository.fetchRemoteVersion() + .do(onNext: { remoteVersion in + print("🚏 λ²„μŠ€μ •λ₯˜μž₯ 원격 버전 확인: \(remoteVersion.busStationVersion)") + }, onError: { error in + print("🚏 λ²„μŠ€μ •λ₯˜μž₯ 원격 버전 확인 쀑 μ—λŸ¬ λ°œμƒ: \(error)") + }) + .flatMap { [weak self] remoteVersion -> Observable in + guard let self = self else { return .error(RxError.unknown) } + + let localVersion = self.versionRepository.fetchLocalVersion() + print("🚏 λ²„μŠ€μ •λ₯˜μž₯ 둜컬 버전 확인: \(localVersion ?? "κΈ°μ‘΄ 파일 μ—†μŒ")") + + let needsUpdate = localVersion != remoteVersion.busStationVersion + print("🚏 [λ²„μŠ€μ •λ₯˜μž₯ 버전 비ꡐ]") + print("둜컬: \(localVersion ?? "파일 μ—†μŒ")") + print("원격: \(remoteVersion.busStationVersion)") + + if needsUpdate { + return self.downloadAndSave( + newVersion: remoteVersion.busStationVersion + ) + } else { + print("🚏 λ²„μŠ€μ •λ₯˜μž₯ 정보가 이미 μ΅œμ‹  λ²„μ „μž…λ‹ˆλ‹€.") + return .just(()) + } + } + } + + private func downloadAndSave(newVersion: String) -> Observable { + print("🚏 downloadAndSave 호좜. bus_station_list.json λ‹€μš΄λ‘œλ“œ") + return fileDownloadRepository.downloadFile( + repo: "BusStationData", + filePath: "bus_station_list.json", + directoryName: "jsons", + fileName: "bus_station_list.json" + ) + .do(onNext: { _ in + + self.versionRepository.save(version: newVersion) + print("🚏 λ‘œμ»¬μ— μ΅œμ‹  λ²„μŠ€μ •λ₯˜μž₯ 정보 μ €μž₯") + }, onError: { error in + print("🚏 파일 λ‹€μš΄λ‘œλ“œ 쀑 μ—λŸ¬ λ°œμƒ: \(error)") + }) + } +} diff --git a/Projects/FileManagerService/Project.swift b/Projects/FileManagerService/Project.swift new file mode 100644 index 00000000..135a9a05 --- /dev/null +++ b/Projects/FileManagerService/Project.swift @@ -0,0 +1,9 @@ +import ProjectDescription +import ProjectDescriptionHelpers + +let project = Project(name: "FileManagerService") { + FileManagerService { + Domain() + FrameworkInfoPlist(marketingVersion: .marketingVersion) + } +} diff --git a/Projects/FileManagerService/Sources/DefaultFileManagerService.swift b/Projects/FileManagerService/Sources/DefaultFileManagerService.swift new file mode 100644 index 00000000..7ea2338d --- /dev/null +++ b/Projects/FileManagerService/Sources/DefaultFileManagerService.swift @@ -0,0 +1,141 @@ +import Foundation + +import Domain +import RxSwift + +public final class DefaultFileManagerService: FileManagerService { + + private let fileManager: FileManager + + public init( + fileManager: FileManager = .default + ) { + self.fileManager = fileManager + } + + public func save( + data: Data, + directoryName: String, + fileName: String + ) -> Observable { + .create { [weak self] observer in + guard let self else { + observer.onError(FileManagerServiceError.unknown) + return Disposables.create() + } + do { + let directoryURL = try self.getDirectoryURL(with: directoryName) + let fileURL = directoryURL.appendingPathComponent(fileName) + try data.write(to: fileURL) + print(fileURL.path) + observer.onNext(()) + observer.onCompleted() + } catch { + observer.onError(error) + } + return Disposables.create() + } + } + + public func fetch( + directoryName: String, + fileName: String + ) -> Observable { + .create { [weak self] observer in + guard let self else { + observer.onError(FileManagerServiceError.unknown) + return Disposables.create() + } + do { + let fileURL = try self.getFileURL( + directoryName: directoryName, + fileName: fileName + ) + let data = try Data(contentsOf: fileURL) + observer.onNext(data) + observer.onCompleted() + } catch { + observer.onError(error) + } + return Disposables.create() + } + } + + public func delete( + directoryName: String, + fileName: String + ) -> Observable { + .create { [weak self] observer in + guard let self else { + observer.onError(FileManagerServiceError.unknown) + return Disposables.create() + } + do { + let fileURL = try self.getFileURL( + directoryName: directoryName, + fileName: fileName + ) + try self.fileManager.removeItem(at: fileURL) + observer.onNext(()) + observer.onCompleted() + } catch { + observer.onError(error) + } + return Disposables.create() + } + } + + public func isExist( + directoryName: String, + fileName: String + ) -> Observable { + do { + let fileURL = try getFileURL( + directoryName: directoryName, + fileName: fileName + ) + return .just(fileManager.fileExists(atPath: fileURL.path)) + } catch { + return .error(error) + } + } +} + +private extension DefaultFileManagerService { + func getDirectoryURL(with name: String) throws -> URL { + let directoryURL = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + )[0].appendingPathComponent(name) + + if !fileManager.fileExists(atPath: directoryURL.path) { + do { + try fileManager.createDirectory( + at: directoryURL, + withIntermediateDirectories: true + ) + } catch { + throw FileManagerServiceError.directoryCreationFailed(error) + } + } + return directoryURL + } + + func getFileURL( + directoryName: String, + fileName: String + ) throws -> URL { + let directoryURL = fileManager.urls( + for: .documentDirectory, + in: .userDomainMask + )[0].appendingPathComponent(directoryName) + + let fileURL = directoryURL.appendingPathComponent(fileName) + + guard fileManager.fileExists(atPath: fileURL.path) else { + throw FileManagerServiceError.fileNotFound + } + + return fileURL + } +} diff --git a/Projects/FileManagerService/Sources/FileManagerService.swift b/Projects/FileManagerService/Sources/FileManagerService.swift new file mode 100644 index 00000000..4ed0b4fc --- /dev/null +++ b/Projects/FileManagerService/Sources/FileManagerService.swift @@ -0,0 +1,27 @@ +import Foundation + +import RxSwift + +public protocol FileManagerService { + + func save( + data: Data, + directoryName: String, + fileName: String + ) -> Observable + + func fetch( + directoryName: String, + fileName: String + ) -> Observable + + func delete( + directoryName: String, + fileName: String + ) -> Observable + + func isExist( + directoryName: String, + fileName: String + ) -> Observable +} diff --git a/Projects/FileManagerService/Sources/FileManagerServiceError.swift b/Projects/FileManagerService/Sources/FileManagerServiceError.swift new file mode 100644 index 00000000..d51ab409 --- /dev/null +++ b/Projects/FileManagerService/Sources/FileManagerServiceError.swift @@ -0,0 +1,18 @@ +import Foundation + +public enum FileManagerServiceError: Error { + /// μ•Œ 수 μ—†λŠ” μ—λŸ¬ + case unknown + /// 디렉토리 생성 μ‹€νŒ¨ + case directoryCreationFailed(Error) + /// 파일 μ €μž₯ μ‹€νŒ¨ + case saveFailed(Error) + /// νŒŒμΌμ„ 찾을 수 μ—†μŒ + case fileNotFound + /// 파일 뢈러였기 μ‹€νŒ¨ + case fetchFailed(Error) + /// 파일 μ‚­μ œ μ‹€νŒ¨ + case deleteFailed(Error) + /// 파일 λ‹€μš΄λ‘œλ“œ μ‹€νŒ¨ + case downloadFailed(Error) +} diff --git a/Projects/NetworkService/Sources/EndPoint/GithubFileDownloadEndPoint.swift b/Projects/NetworkService/Sources/EndPoint/GithubFileDownloadEndPoint.swift new file mode 100644 index 00000000..da01e66c --- /dev/null +++ b/Projects/NetworkService/Sources/EndPoint/GithubFileDownloadEndPoint.swift @@ -0,0 +1,25 @@ +import Foundation +import Core + +public struct GithubFileDownloadEndPoint: EndPoint { + private let owner = "Pepsi-Club" + private let branch = "main" + private let repo: String + private let filePath: String + + public init(repo: String, filePath: String) { + self.repo = repo + self.filePath = filePath + } + + public var scheme: Scheme { .https } + public var host: String { "raw.githubusercontent.com" } + public var port: String { "" } + public var path: String { "/\(owner)/\(repo)/\(branch)/\(filePath)" } + public var query: [String: String] { [:] } + public var body: [String: Any] { [:] } + public var method: HTTPMethod { .get } + public var header: [String: String] { + ["Authorization": "token \(String.githubAccessToken)"] + } +} diff --git a/Tuist/ProjectDescriptionHelpers/InfoPlist/SecretInfoPlist.swift b/Tuist/ProjectDescriptionHelpers/InfoPlist/SecretInfoPlist.swift index f5eb39c3..a987ba87 100644 --- a/Tuist/ProjectDescriptionHelpers/InfoPlist/SecretInfoPlist.swift +++ b/Tuist/ProjectDescriptionHelpers/InfoPlist/SecretInfoPlist.swift @@ -15,6 +15,7 @@ public struct SecretInfoPlist: InfoPlistBuildable { "INQUIRY_URL": "$(INQUIRY_URL)", "APPSTORE_ID": "$(APPSTORE_ID)", "DOMAIN_URL": "$(DOMAIN_URL)", + "GITHUB_ACCESS_TOKEN": "$(GITHUB_ACCESS_TOKEN)", ] } diff --git a/Tuist/ProjectDescriptionHelpers/Module/Local/FileManagerService.swift b/Tuist/ProjectDescriptionHelpers/Module/Local/FileManagerService.swift new file mode 100644 index 00000000..3a7a69e3 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/Module/Local/FileManagerService.swift @@ -0,0 +1,16 @@ + +public struct FileManagerService: FrameworkTarget { + public let product: Product = .framework + public let infoPlist: InfoPlist? + public let dependencies: [TargetDependency] + + public init( + @TargetComponentBuilder dependencies builder: () -> TargetComponentBuilder = { + .init() + } + ) { + let builder = builder() + self.dependencies = builder.buildTargetDependency() + self.infoPlist = builder.buildInfoPlist() + } +}