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
1 change: 1 addition & 0 deletions Application/App/Sources/App/DevLogApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ struct DevLogApp: App {
networkConnectivityUseCase: container.resolve(ObserveNetworkConnectivityUseCase.self),
systemThemeUseCase: container.resolve(ObserveSystemThemeUseCase.self),
trackAnalyticsEventUseCase: container.resolve(TrackAnalyticsEventUseCase.self),
checkAppUpdateUseCase: container.resolve(CheckAppUpdateUseCase.self),
widgetURLTab: { MainTab(widgetURL: $0) },
windowEvent: windowEvent,
pushNotificationTodoIdPublisher: PushNotificationRoute.shared.observe(),
Expand Down
2 changes: 2 additions & 0 deletions Application/App/Sources/Resource/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>APP_STORE_URL</key>
<string>$(APP_STORE_URL)</string>
<key>TESTFLIGHT_URL</key>
<string>$(TESTFLIGHT_URL)</string>
<key>CFBundleDevelopmentRegion</key>
Expand Down
51 changes: 51 additions & 0 deletions Application/App/Sources/Resource/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1493,6 +1493,57 @@
}
}
},
"root_app_update_action" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Update"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "업데이트"
}
}
}
},
"root_app_update_message" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Update to the latest version to continue using DevLog."
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "DevLog를 계속 사용하려면 최신 버전으로 업데이트해주세요."
}
}
}
},
"root_app_update_title" : {
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Update Required"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "업데이트 필요"
}
}
}
},
"root_network_disconnected_message" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
6 changes: 6 additions & 0 deletions Application/Data/Sources/DataAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ public final class DataAssembler: Assembler {
)
}

container.register(AppVersionRepository.self) {
AppVersionRepositoryImpl(
service: container.resolve(AppVersionConfigurationService.self)
)
}

container.register(AuthDataRepository.self) {
AuthDataRepositoryImpl(
authService: container.resolve(AuthService.self),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// AppVersionConfigurationService.swift
// Data
//
// Created by opfic on 7/22/26.
//

public protocol AppVersionConfigurationService {
func fetchRequiredVersion() async throws -> String
}
20 changes: 20 additions & 0 deletions Application/Data/Sources/Repository/AppVersionRepositoryImpl.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// AppVersionRepositoryImpl.swift
// Data
//
// Created by opfic on 7/22/26.
//

import Domain

final class AppVersionRepositoryImpl: AppVersionRepository {
private let service: AppVersionConfigurationService

init(service: AppVersionConfigurationService) {
self.service = service
}

func fetchRequiredVersion() async throws -> String {
try await service.fetchRequiredVersion()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// AppVersionRepositoryImplTests.swift
// DataTests
//
// Created by opfic on 7/22/26.
//

import Testing
@testable import Data

struct AppVersionRepositoryImplTests {
@Test("필수 버전 조회는 구성 서비스의 값을 반환한다")
func 필수_버전_조회는_구성_서비스의_값을_반환한다() async throws {
let service = AppVersionConfigurationServiceSpy(result: .success("1.5"))
let repository = AppVersionRepositoryImpl(service: service)

#expect(try await repository.fetchRequiredVersion() == "1.5")
#expect(await service.fetchCallCount() == 1)
}

@Test("필수 버전 조회는 구성 서비스의 오류를 그대로 반환한다")
func 필수_버전_조회는_구성_서비스의_오류를_그대로_반환한다() async {
let service = AppVersionConfigurationServiceSpy(
result: .failure(AppVersionConfigurationServiceTestError.fetchFailed)
)
let repository = AppVersionRepositoryImpl(service: service)

await #expect(throws: AppVersionConfigurationServiceTestError.fetchFailed) {
try await repository.fetchRequiredVersion()
}
}
}

private actor AppVersionConfigurationServiceSpy: AppVersionConfigurationService {
private let result: Result<String, Error>
private var count = 0

init(result: Result<String, Error>) {
self.result = result
}

func fetchRequiredVersion() async throws -> String {
count += 1
return try result.get()
}

func fetchCallCount() -> Int {
count
}
}

private enum AppVersionConfigurationServiceTestError: Error {
case fetchFailed
}
2 changes: 1 addition & 1 deletion Application/Domain/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ let project = Project.devlogFramework(
dependencies: [
.project(target: "Core", path: "../Core")
],
hasTests: false
hasTests: true
)
7 changes: 7 additions & 0 deletions Application/Domain/Sources/DomainAssembler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ public final class DomainAssembler: Assembler {

public func assemble(_ container: any DIContainer) {
registerAnalyticsUseCases(container)
registerAppUpdateUseCases(container)
registerAuthUseCases(container)
registerConnectivityUseCases(container)
registerAuthProviderUseCases(container)
Expand All @@ -25,6 +26,12 @@ public final class DomainAssembler: Assembler {
}

private extension DomainAssembler {
func registerAppUpdateUseCases(_ container: any DIContainer) {
container.register(CheckAppUpdateUseCase.self) {
CheckAppUpdateUseCaseImpl(container.resolve(AppVersionRepository.self))
}
}

func registerAnalyticsUseCases(_ container: any DIContainer) {
container.register(TrackAnalyticsEventUseCase.self) {
TrackAnalyticsEventUseCaseImpl(container.resolve(AnalyticsRepository.self))
Expand Down
40 changes: 40 additions & 0 deletions Application/Domain/Sources/Entity/AppVersion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// AppVersion.swift
// Domain
//
// Created by opfic on 7/22/26.
//

public struct AppVersion: Comparable {
private let components: [Int]

public init(marketingVersion: String, buildNumber: String) throws {
try self.init("\(marketingVersion).\(buildNumber)")
}

init(_ value: String) throws {
let rawComponents = value.split(separator: ".", omittingEmptySubsequences: false)
guard !rawComponents.isEmpty,
rawComponents.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else {
throw DomainLayerError.invalidData(context: "appVersion")
}

let components = rawComponents.compactMap { Int($0) }
guard components.count == rawComponents.count else {
throw DomainLayerError.invalidData(context: "appVersion")
}
self.components = components
}

public static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)

for index in 0..<count {
let lhsComponent = index < lhs.components.count ? lhs.components[index] : 0
let rhsComponent = index < rhs.components.count ? rhs.components[index] : 0
guard lhsComponent != rhsComponent else { continue }
return lhsComponent < rhsComponent
}
return false
}
}
10 changes: 10 additions & 0 deletions Application/Domain/Sources/Protocol/AppVersionRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// AppVersionRepository.swift
// Domain
//
// Created by opfic on 7/22/26.
//

public protocol AppVersionRepository {
func fetchRequiredVersion() async throws -> String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// CheckAppUpdateUseCase.swift
// Domain
//
// Created by opfic on 7/22/26.
//

public protocol CheckAppUpdateUseCase {
func execute() async throws -> Bool
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// CheckAppUpdateUseCaseImpl.swift
// Domain
//
// Created by opfic on 7/22/26.
//

import Foundation

public final class CheckAppUpdateUseCaseImpl: CheckAppUpdateUseCase {
private let repository: AppVersionRepository

init(_ repository: AppVersionRepository) {
self.repository = repository
}

public func execute() async throws -> Bool {
let requiredVersionValue = try await repository.fetchRequiredVersion()
let requiredVersion = try AppVersion(requiredVersionValue)
let currentVersion = try currentVersion()
return currentVersion < requiredVersion
}

private func currentVersion() throws -> AppVersion {
guard let marketingVersion = Bundle.main.object(
forInfoDictionaryKey: "CFBundleShortVersionString"
) as? String,
let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String else {
throw DomainLayerError.invalidData(context: "appVersion")
}

return try AppVersion(
marketingVersion: marketingVersion,
buildNumber: buildNumber
)
}
}
25 changes: 25 additions & 0 deletions Application/Domain/Tests/Entity/AppVersionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// AppVersionTests.swift
// DomainTests
//
// Created by opfic on 7/22/26.
//

import Testing
@testable import Domain

struct AppVersionTests {
@Test("마케팅 버전 형식이 잘못되면 invalidData 오류를 반환한다")
func 마케팅_버전_형식이_잘못되면_invalidData_오류를_반환한다() {
#expect(throws: DomainLayerError.self) {
try AppVersion(marketingVersion: "1..5", buildNumber: "127")
}
}

@Test("빌드 번호 형식이 잘못되면 invalidData 오류를 반환한다")
func 빌드_번호_형식이_잘못되면_invalidData_오류를_반환한다() {
#expect(throws: DomainLayerError.self) {
try AppVersion(marketingVersion: "1.5", buildNumber: "127a")
}
}
}
Loading
Loading