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
7 changes: 5 additions & 2 deletions Moru/Moru/App/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,9 +497,12 @@ struct AppRouter: View {
UIApplication.shared.open(url)
},
onResetSucceeded: resetToNewUserFlow,
onServerVoiceSelectionDidSucceed: { memberID in
onServerVoiceSelectionDidSucceed: { selection in
dependencies.routineTTSWarmupCoordinator?
.serverVoiceSelectionDidChange(memberID: memberID)
.serverVoiceSelectionDidChange(
memberID: selection.memberID,
selectionVersion: selection.selectionVersion
)
}
)
let mainTabState = state.mainTabState
Expand Down
3 changes: 2 additions & 1 deletion Moru/Moru/App/DependencyContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ struct DependencyContainer {
routineRepository: routineRepository,
audioCache: routineTTSAudioCache,
downloader: RoutineTTSAudioDownloader(),
sessionIdentityProvider: sessionIdentityProvider
sessionIdentityProvider: sessionIdentityProvider,
voiceSelectionVersionStore: UserDefaultsRoutineTTSVoiceSelectionVersionStore()
)
routineTTSWarmupCoordinator = warmupCoordinator
let remoteFirstGuidancePlayer = RemoteFirstRoutineGuidancePlayer(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
//
// UserDefaultsRoutineTTSVoiceSelectionVersionStore.swift
// Moru
//

import Foundation

@MainActor
final class UserDefaultsRoutineTTSVoiceSelectionVersionStore:
RoutineTTSVoiceSelectionVersionStoring {
static let defaultKeyPrefix = "routine-tts-voice-selection-version-v1"

private let userDefaults: UserDefaults
private let keyPrefix: String

init(
userDefaults: UserDefaults = .standard,
keyPrefix: String = UserDefaultsRoutineTTSVoiceSelectionVersionStore.defaultKeyPrefix
) {
self.userDefaults = userDefaults
self.keyPrefix = keyPrefix
}

func selectionVersion(forMemberID memberID: Int64) -> Int64? {
guard let number = userDefaults.object(forKey: key(for: memberID)) as? NSNumber else {
return nil
}
let version = number.int64Value
guard version >= 0 else {
removeSelectionVersion(forMemberID: memberID)
return nil
}
return version
}

func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64) {
guard version >= 0 else {
removeSelectionVersion(forMemberID: memberID)
return
}
userDefaults.set(NSNumber(value: version), forKey: key(for: memberID))
}

func removeSelectionVersion(forMemberID memberID: Int64) {
userDefaults.removeObject(forKey: key(for: memberID))
}

private func key(for memberID: Int64) -> String {
"\(keyPrefix).\(memberID)"
}
}
1 change: 1 addition & 0 deletions Moru/Moru/Data/Remote/AccountServer/AccountServerDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,5 @@ nonisolated struct TTSUpdateResponseDTO:
let ttsId: Int64?
let voiceCode: String?
let displayName: String?
let selectionVersion: Int64?
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ nonisolated enum AccountServerTarget: MoruTargetType {
"memberId": 98,
"ttsId": \(request.ttsId),
"voiceCode": "HYEONU",
"displayName": "현우"
"displayName": "현우",
"selectionVersion": 0
}
}
""".utf8
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,8 @@ nonisolated private extension TTSUpdateResponseDTO {
memberID: memberId,
ttsID: ttsId,
voiceCode: voiceCode,
displayName: displayName
displayName: displayName,
selectionVersion: try validSelectionVersion(selectionVersion)
)
}
}
Expand Down Expand Up @@ -256,6 +257,16 @@ nonisolated private func normalizedOptionalText(
return try normalizedRequiredText(value)
}

nonisolated private func validSelectionVersion(
_ value: Int64?
) throws -> Int64? {
guard let value else { return nil }
guard value >= 0 else {
throw AccountServerRemoteError.invalidResponse
}
return value
}

nonisolated private func previewAudioURL(
from value: String?
) throws -> URL? {
Expand Down
13 changes: 13 additions & 0 deletions Moru/Moru/Data/Remote/RoutineTTS/RemoteRoutineTTSService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ where Element == RoutineTTSStepResponseDTO {
content: try requiredRoutineTTSText(step.content),
introText: introText,
status: status,
selectionVersion: try validRoutineTTSSelectionVersion(
step.selectionVersion
),
audioURL: playableAudioURL(
status: status,
introText: introText,
Expand Down Expand Up @@ -167,6 +170,16 @@ nonisolated private func normalizedOptionalRoutineTTSText(
return normalized.isEmpty ? nil : normalized
}

nonisolated private func validRoutineTTSSelectionVersion(
_ value: Int64?
) throws -> Int64? {
guard let value else { return nil }
guard value >= 0 else {
throw RoutineTTSRemoteError.invalidResponse
}
return value
}

nonisolated private func playableAudioURL(
status: ServerRoutineTTSGenerationStatus,
introText: String?,
Expand Down
1 change: 1 addition & 0 deletions Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ nonisolated struct RoutineTTSStepResponseDTO:
let ttsIntro: String?
let ttsStatus: String?
let s3Url: String?
let selectionVersion: Int64?
}
3 changes: 2 additions & 1 deletion Moru/Moru/Data/Remote/RoutineTTS/RoutineTTSTarget.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ nonisolated enum RoutineTTSTarget:
"content": "목 스트레칭",
"ttsIntro": "이제 목을 부드럽게 풀어볼까요?",
"ttsStatus": "COMPLETED",
"s3Url": "https://moru-tts.s3.ap-northeast-2.amazonaws.com/101.mp3"
"s3Url": "https://moru-tts.s3.ap-northeast-2.amazonaws.com/101.mp3",
"selectionVersion": 0
}
]
}
Expand Down
15 changes: 15 additions & 0 deletions Moru/Moru/Domain/Models/AccountServerModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,19 @@ nonisolated struct ServerTTSSelection: Equatable, Sendable {
let ttsID: Int64
let voiceCode: String
let displayName: String
let selectionVersion: Int64?

init(
memberID: Int64,
ttsID: Int64,
voiceCode: String,
displayName: String,
selectionVersion: Int64? = nil
) {
self.memberID = memberID
self.ttsID = ttsID
self.voiceCode = voiceCode
self.displayName = displayName
self.selectionVersion = selectionVersion
}
}
25 changes: 25 additions & 0 deletions Moru/Moru/Domain/Models/RoutineTTSModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,40 @@ nonisolated struct ServerRoutineTTSStep: Equatable, Sendable {
let content: String
let introText: String?
let status: ServerRoutineTTSGenerationStatus
let selectionVersion: Int64?

/// A remote audio URL is exposed only when the complete playable contract
/// is satisfied. Callers must still download and validate the asset before
/// playback rather than streaming this URL directly.
let audioURL: URL?

init(
stepID: Int64,
content: String,
introText: String?,
status: ServerRoutineTTSGenerationStatus,
selectionVersion: Int64? = nil,
audioURL: URL?
) {
self.stepID = stepID
self.content = content
self.introText = introText
self.status = status
self.selectionVersion = selectionVersion
self.audioURL = audioURL
}

var isPlayable: Bool {
audioURL != nil
}

func matchesCurrentSelectionVersion(_ currentSelectionVersion: Int64?) -> Bool {
// A missing side means a legacy server contract, not a mismatch.
guard let currentSelectionVersion, let selectionVersion else {
return true
}
return currentSelectionVersion == selectionVersion
}
}

nonisolated enum ServerRoutineTTSGenerationStatus: Equatable, Sendable {
Expand Down
7 changes: 6 additions & 1 deletion Moru/Moru/Domain/Services/RoutineTTSCuePlanResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ nonisolated struct RoutineTTSCuePlanResolver: Sendable {
routineLocalID: UUID,
groupBinding: RoutineServerBinding?,
routineBinding: RoutineServerBinding?,
response: [ServerRoutineTTSRoutine]
response: [ServerRoutineTTSRoutine],
currentSelectionVersion: Int64? = nil
) -> RoutineTTSCuePlanResolution {
guard let groupBinding,
groupBinding.entityKind == .routineGroup,
Expand Down Expand Up @@ -67,6 +68,10 @@ nonisolated struct RoutineTTSCuePlanResolver: Sendable {
hasPendingStep = true

case .completed:
guard step.matchesCurrentSelectionVersion(currentSelectionVersion) else {
hasPendingStep = true
continue
}
guard let audioURL = step.audioURL,
audioURL.scheme?.lowercased() == "https",
audioURL.host != nil else {
Expand Down
68 changes: 63 additions & 5 deletions Moru/Moru/Domain/Services/RoutineTTSWarmupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ protocol RoutineTTSWarming: AnyObject {
) async
}

@MainActor
protocol RoutineTTSVoiceSelectionVersionStoring: AnyObject {
func selectionVersion(forMemberID memberID: Int64) -> Int64?
func setSelectionVersion(_ version: Int64, forMemberID memberID: Int64)
func removeSelectionVersion(forMemberID memberID: Int64)
}

extension RoutineTTSWarming {
/// Keeps lightweight test and preview doubles source-compatible while the
/// production coordinator supplies the foreground readiness wait.
Expand All @@ -87,6 +94,11 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
let keys: [RoutineTTSAudioCacheKey]
}

private struct CurrentVoiceSelection {
let identity: AccountSessionIdentity
let version: Int64
}

private struct RoutineTTSLocalFingerprint: Equatable {
let normalizedTitle: String
let type: RoutineStepType
Expand Down Expand Up @@ -136,6 +148,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
private let resolver: RoutineTTSCuePlanResolver
private let foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy
private let diagnostics: RoutineTTSDiagnostics
private let voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring
private weak var playbackSessionInvalidator:
(any RoutineTTSPlaybackSessionInvalidating)?

Expand All @@ -144,6 +157,9 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
private var sessionTransitionTask: Task<Void, Never>?
private var isSceneActive = false
private var observedIdentity: AccountSessionIdentity?
/// Restored only from a version previously received from this member's
/// successful PATCH response on this device.
private var currentVoiceSelection: CurrentVoiceSelection?
/// If a purge fails, normalized cache keys can still resolve old bytes.
/// Keep the affected account muted until a later purge succeeds instead of
/// risking the newly selected voice playing stale audio.
Expand All @@ -160,7 +176,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
resolver: RoutineTTSCuePlanResolver = RoutineTTSCuePlanResolver(),
foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy =
RoutineTTSForegroundPollingPolicy(),
diagnostics: RoutineTTSDiagnostics = RoutineTTSDiagnostics()
diagnostics: RoutineTTSDiagnostics = RoutineTTSDiagnostics(),
voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring
) {
self.remoteService = remoteService
self.bindingRepository = bindingRepository
Expand All @@ -172,7 +189,10 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
self.resolver = resolver
self.foregroundPollingPolicy = foregroundPollingPolicy
self.diagnostics = diagnostics
observedIdentity = sessionIdentityProvider.currentAccountSessionIdentity
self.voiceSelectionVersionStore = voiceSelectionVersionStore
let identity = sessionIdentityProvider.currentAccountSessionIdentity
observedIdentity = identity
currentVoiceSelection = restoredVoiceSelection(for: identity)
}

func setSceneActive(_ isActive: Bool) {
Expand All @@ -196,6 +216,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
let previousIdentity = observedIdentity
let currentIdentity = sessionIdentityProvider?.currentAccountSessionIdentity
observedIdentity = currentIdentity
currentVoiceSelection = restoredVoiceSelection(for: currentIdentity)
preparedPlans.removeAll()
var memberIDsToPurge = Set<Int64>()
if let previousIdentity {
Expand Down Expand Up @@ -243,12 +264,28 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
/// a new signed URL. The cache key intentionally normalizes URL queries, so
/// invalidate this account's namespace before any next cue can use old
/// bytes. Rewarming is deferred until the purge completes.
func serverVoiceSelectionDidChange(memberID: Int64) {
func serverVoiceSelectionDidChange(
memberID: Int64,
selectionVersion: Int64? = nil
) {
guard let identity = sessionIdentityProvider?.currentAccountSessionIdentity,
identity.memberID == memberID else {
return
}

if let selectionVersion, selectionVersion >= 0 {
voiceSelectionVersionStore.setSelectionVersion(
selectionVersion,
forMemberID: memberID
)
currentVoiceSelection = CurrentVoiceSelection(
identity: identity,
version: selectionVersion
)
} else {
voiceSelectionVersionStore.removeSelectionVersion(forMemberID: memberID)
currentVoiceSelection = nil
}
preparedPlans.removeAll()
cacheUnavailableMemberIDs.insert(memberID)
let previousTransition = sessionTransitionTask
Expand Down Expand Up @@ -610,7 +647,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
routineLocalID: routineLocalID,
groupBinding: groupBinding,
routineBinding: routineBinding,
response: response
response: response,
currentSelectionVersion: currentSelectionVersion(for: identity)
)
let assets: [RoutineTTSResolvedAsset]
switch resolution {
Expand Down Expand Up @@ -837,7 +875,8 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
routineLocalID: routineLocalID,
groupBinding: groupBinding,
routineBinding: routineBinding,
response: response
response: response,
currentSelectionVersion: currentSelectionVersion(for: identity)
) {
case .pending:
return .pendingGeneration
Expand Down Expand Up @@ -1077,6 +1116,25 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
!cacheUnavailableMemberIDs.contains(identity.memberID)
}

private func currentSelectionVersion(
for identity: AccountSessionIdentity
) -> Int64? {
guard currentVoiceSelection?.identity == identity else { return nil }
return currentVoiceSelection?.version
}

private func restoredVoiceSelection(
for identity: AccountSessionIdentity?
) -> CurrentVoiceSelection? {
guard let identity,
let version = voiceSelectionVersionStore.selectionVersion(
forMemberID: identity.memberID
) else {
return nil
}
return CurrentVoiceSelection(identity: identity, version: version)
}

private func validateCurrentBinding(
for plan: PreparedPlan,
planKey: LocalPlanKey,
Expand Down
Loading
Loading