Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions MLS/MLS.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions MLS/MLSAppFeature/.claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(swift build:*)",
"Bash(rm:*)",
"Bash(mkdir:*)",
"Bash(swift test:*)",
"Bash(find:*)"
]
}
}
8 changes: 8 additions & 0 deletions MLS/MLSAppFeature/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
52 changes: 52 additions & 0 deletions MLS/MLSAppFeature/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// swift-tools-version: 6.2
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
name: "MLSAppFeature",
platforms: [.iOS(.v15)],
products: [
// Interface: 외부 인터페이스와 모델을 제공하는 모듈
.library(
name: "MLSAppFeatureInterface",
targets: ["MLSAppFeatureInterface"]
),
// Feature: 실제 기능이 구현된 모듈
.library(
name: "MLSAppFeature",
targets: ["MLSAppFeature"]
),
// Testing: 단위 테스트나 Example 앱에서 사용될 Mock 데이터를 제공하는 모듈
.library(
name: "MLSAppFeatureTesting",
targets: ["MLSAppFeatureTesting"]
)
],
targets: [
// Interface 모듈 (도메인 모델 및 프로토콜)
.target(
name: "MLSAppFeatureInterface",
dependencies: []
),
// Feature 모듈 (실제 구현)
.target(
name: "MLSAppFeature",
dependencies: ["MLSAppFeatureInterface"]
),
// Testing 모듈 (Mock 객체)
.target(
name: "MLSAppFeatureTesting",
dependencies: ["MLSAppFeatureInterface"]
),
// Tests 모듈
.testTarget(
name: "MLSAppFeatureTests",
dependencies: [
"MLSAppFeature",
"MLSAppFeatureInterface",
"MLSAppFeatureTesting"
]
)
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

import MLSAppFeatureInterface

/// UserDefaults를 사용하는 Local Data Source
final class UserDefaultsDataSource {
nonisolated(unsafe) private let userDefaults: UserDefaults
private let versionKey = "com.mls.updateChecker.skippedVersion"
private let dateKey = "com.mls.updateChecker.skippedDate"

init(userDefaults: UserDefaults = .standard) {
self.userDefaults = userDefaults
}

/// 스킵 버전을 저장합니다
func saveSkipVersion(_ version: Version, skippedAt date: Date) {
userDefaults.set(version.versionString, forKey: versionKey)
userDefaults.set(date.timeIntervalSince1970, forKey: dateKey)
}

/// 저장된 스킵 버전을 조회합니다
func getSkippedVersion() -> Version? {
guard let versionString = userDefaults.string(forKey: versionKey) else {
return nil
}
return Version(versionString: versionString)
}

/// 스킵 날짜를 조회합니다
func getSkippedDate() -> Date? {
let timestamp = userDefaults.double(forKey: dateKey)
guard timestamp > 0 else { return nil }
return Date(timeIntervalSince1970: timestamp)
}

/// 스킵 정보를 삭제합니다
func clearSkipInfo() {
userDefaults.removeObject(forKey: versionKey)
userDefaults.removeObject(forKey: dateKey)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation

import MLSAppFeatureInterface

/// iTunes Search API를 통해 앱스토어 버전 정보를 조회하는 Remote Data Source
final class AppStoreService {
private let urlSession: URLSession

init(urlSession: URLSession = .shared) {
self.urlSession = urlSession
}

/// 앱스토어에서 최신 버전을 조회합니다
/// - Parameter appID: 앱스토어 앱 ID
/// - Returns: 최신 버전 정보
/// - Throws: AppStoreError
func fetchLatestVersion(appID: String) async throws -> Version {
guard let url = URL(string: "https://itunes.apple.com/lookup?id=\(appID)") else {
throw AppStoreError.invalidURL
}

let (data, response) = try await urlSession.data(from: url)

guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw AppStoreError.invalidResponse
}

guard let lookupResponse = try? JSONDecoder().decode(AppStoreLookupResponse.self, from: data) else {
throw AppStoreError.parsingError
}

guard let result = lookupResponse.results.first,
let version = Version(versionString: result.version) else {
throw AppStoreError.versionNotFound
}

return version
}
}

// MARK: - Response Models

private struct AppStoreLookupResponse: Decodable {
let results: [AppStoreResult]
}

private struct AppStoreResult: Decodable {
let version: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

import MLSAppFeatureInterface

/// 앱스토어 정보를 조회하는 Repository 구현체
public final class AppStoreRepository: AppStoreRepositoryProtocol, @unchecked Sendable {
private let remoteDataSource: AppStoreService

public init() {
self.remoteDataSource = AppStoreService()
}

/// 테스트용 초기화자
init(remoteDataSource: AppStoreService) {
self.remoteDataSource = remoteDataSource
}

public func fetchLatestVersion(appID: String) async throws -> Version {
return try await remoteDataSource.fetchLatestVersion(appID: appID)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import Foundation

import MLSAppFeatureInterface

/// 업데이트 스킵 정보를 관리하는 Repository 구현체
public final class UpdateSkipRepository: UpdateSkipRepositoryProtocol, @unchecked Sendable {
private let localDataSource: UserDefaultsDataSource
private let skipDuration: TimeInterval

/// UpdateSkipRepository 초기화
/// - Parameter skipDuration: 스킵 유효 기간 (기본값: 7일)
public init(skipDuration: TimeInterval = 7 * 24 * 60 * 60) {
self.localDataSource = UserDefaultsDataSource()
self.skipDuration = skipDuration
}

/// 테스트용 초기화자
/// - Parameters:
/// - localDataSource: 로컬 데이터 소스
/// - skipDuration: 스킵 유효 기간
init(localDataSource: UserDefaultsDataSource, skipDuration: TimeInterval = 7 * 24 * 60 * 60) {
self.localDataSource = localDataSource
self.skipDuration = skipDuration
}

public func saveSkipVersion(_ version: Version, skippedAt date: Date) {
localDataSource.saveSkipVersion(version, skippedAt: date)
}

public func isSkipValid(for version: Version) -> Bool {
guard let skippedVersion = localDataSource.getSkippedVersion(),
let skippedDate = localDataSource.getSkippedDate(),
skippedVersion == version else {
return false
}

// 스킵 기간이 지났는지 확인
let elapsed = Date().timeIntervalSince(skippedDate)
return elapsed >= 0 && elapsed < skipDuration
}

public func clearSkipInfo() {
localDataSource.clearSkipInfo()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

import MLSAppFeatureInterface

/// 앱 업데이트 체크 Use Case 구현체
public final class UpdateCheckerUseCase: UpdateCheckerUseCaseProtocol {
private let appStoreRepository: AppStoreRepositoryProtocol
private let skipRepository: UpdateSkipRepositoryProtocol
private let appID: String

/// UpdateCheckerUseCase 초기화
/// - Parameters:
/// - appID: 앱스토어 앱 ID
/// - appStoreRepository: 앱스토어 정보 조회 Repository
/// - skipRepository: 스킵 정보 관리 Repository
public init(
appID: String,
appStoreRepository: AppStoreRepositoryProtocol,
skipRepository: UpdateSkipRepositoryProtocol
) {
self.appID = appID
self.appStoreRepository = appStoreRepository
self.skipRepository = skipRepository
}

public func checkUpdate(currentVersion: Version) async throws -> UpdateStatus {
let latestVersion = try await appStoreRepository.fetchLatestVersion(appID: appID)

// 최신 버전이 현재 버전보다 낮거나 같으면 업데이트 불필요
guard latestVersion > currentVersion else {
return .none
}

// major 버전이 다르면 강제 업데이트
if latestVersion.major != currentVersion.major {
return .force(latestVersion: latestVersion)
}

// minor 또는 patch 차이면 선택 업데이트
// 단, 사용자가 이전에 스킵했고 7일이 지나지 않았으면 none 반환
if skipRepository.isSkipValid(for: latestVersion) {
return .none
}

return .optional(latestVersion: latestVersion)
}

public func skipUpdate(version: Version) {
skipRepository.saveSkipVersion(version, skippedAt: Date())
}

public func clearSkipInfo() {
skipRepository.clearSkipInfo()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

/// 앱 업데이트 상태를 나타내는 열거형
public enum UpdateStatus: Equatable, Sendable {
/// 강제 업데이트 필요 (major 버전 차이)
case force(latestVersion: Version)

/// 선택적 업데이트 가능 (minor 또는 patch 차이)
case optional(latestVersion: Version)

/// 업데이트 불필요
case none
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

/// 앱 버전을 나타내는 도메인 엔티티
/// major.minor.patch 형식의 시맨틱 버저닝을 지원합니다
public struct Version: Equatable, Comparable, Sendable {
public let major: Int
public let minor: Int
public let patch: Int

public init(major: Int, minor: Int, patch: Int) {
self.major = major
self.minor = minor
self.patch = patch
}

/// 버전 문자열로부터 Version 객체를 생성합니다
/// - Parameter versionString: "1.2.3" 형식의 버전 문자열
/// - Returns: 파싱에 성공하면 Version 객체, 실패하면 nil
public init?(versionString: String) {
let components = versionString.split(separator: ".").compactMap { Int($0) }
guard components.count >= 2 else { return nil }

self.major = components[0]
self.minor = components[1]
self.patch = components.count > 2 ? components[2] : 0
}

/// 버전을 문자열로 반환합니다
public var versionString: String {
"\(major).\(minor).\(patch)"
}

public static func < (lhs: Version, rhs: Version) -> Bool {
if lhs.major != rhs.major { return lhs.major < rhs.major }
if lhs.minor != rhs.minor { return lhs.minor < rhs.minor }
return lhs.patch < rhs.patch
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import Foundation

/// 앱스토어 정보를 조회하는 Repository 프로토콜
public protocol AppStoreRepositoryProtocol: Sendable {
/// 앱스토어에서 최신 버전을 조회합니다
/// - Parameter appID: 앱스토어 앱 ID
/// - Returns: 최신 버전 정보
/// - Throws: 조회 실패 시 AppStoreError
func fetchLatestVersion(appID: String) async throws -> Version
}

/// 앱스토어 조회 시 발생할 수 있는 에러
public enum AppStoreError: Error, Equatable, Sendable {
case invalidURL
case networkFailure
case invalidResponse
case versionNotFound
case parsingError
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// 업데이트 스킵 정보를 관리하는 Repository 프로토콜
public protocol UpdateSkipRepositoryProtocol: Sendable {
/// 특정 버전에 대한 스킵 정보를 저장합니다
/// - Parameters:
/// - version: 스킵할 버전
/// - date: 스킵 날짜
func saveSkipVersion(_ version: Version, skippedAt date: Date)

/// 특정 버전에 대한 스킵 정보가 유효한지 확인합니다
/// - Parameter version: 확인할 버전
/// - Returns: 스킵 정보가 유효하면 true (7일 이내)
func isSkipValid(for version: Version) -> Bool

/// 저장된 스킵 정보를 삭제합니다
func clearSkipInfo()
}
Loading
Loading