Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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,43 @@
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)
userDefaults.synchronize()
}
Comment thread
dongglehada marked this conversation as resolved.

/// 저장된 스킵 버전을 조회합니다
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)
userDefaults.synchronize()
}
Comment thread
dongglehada marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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
}

let lookupResponse = try JSONDecoder().decode(AppStoreLookupResponse.self, from: data)
Comment thread
dongglehada marked this conversation as resolved.
Outdated

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 {
nonisolated(unsafe) private let remoteDataSource: AppStoreService
Comment thread
dongglehada marked this conversation as resolved.
Outdated

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 {
nonisolated(unsafe) private let localDataSource: UserDefaultsDataSource
Comment thread
dongglehada marked this conversation as resolved.
Outdated
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