Skip to content
Closed
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
12 changes: 6 additions & 6 deletions Moru/Moru.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
MORU_APPLE_SIGN_IN_ENABLED = YES;
MORU_GOOGLE_IOS_CLIENT_ID = "800384412803-r62hbcns8s3jdkjaq5failk863bl19nv.apps.googleusercontent.com";
MORU_GOOGLE_REVERSED_CLIENT_ID = "com.googleusercontent.apps.800384412803-r62hbcns8s3jdkjaq5failk863bl19nv";
Expand Down Expand Up @@ -482,7 +482,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
MORU_APPLE_SIGN_IN_ENABLED = YES;
MORU_GOOGLE_IOS_CLIENT_ID = "800384412803-r62hbcns8s3jdkjaq5failk863bl19nv.apps.googleusercontent.com";
MORU_GOOGLE_REVERSED_CLIENT_ID = "com.googleusercontent.apps.800384412803-r62hbcns8s3jdkjaq5failk863bl19nv";
Expand Down Expand Up @@ -519,7 +519,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
PRODUCT_BUNDLE_IDENTIFIER = com.teammoru.MoruTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -544,7 +544,7 @@
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
PRODUCT_BUNDLE_IDENTIFIER = com.teammoru.MoruTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -563,7 +563,7 @@
DEVELOPMENT_TEAM = Z7FSDLFCMK;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
PRODUCT_BUNDLE_IDENTIFIER = com.teammoru.MoruUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand All @@ -581,7 +581,7 @@
DEVELOPMENT_TEAM = Z7FSDLFCMK;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2.4.5;
MARKETING_VERSION = 2.4.6;
PRODUCT_BUNDLE_IDENTIFIER = com.teammoru.MoruUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
Expand Down
8 changes: 8 additions & 0 deletions Moru/Moru/App/AppBootstrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import Combine
import Foundation
import OSLog
import SwiftData

struct BootstrappedApp {
Expand Down Expand Up @@ -101,6 +102,10 @@ struct DefaultAppBootstrapPreflight: AppBootstrapPreflightPreparing {
@MainActor
final class AppBootstrapper: ObservableObject {
static let installationMarkerKey = "app-installation-marker-v1"
private static let bootstrapLogger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru",
category: "AppBootstrapper"
)

@Published private(set) var state: AppBootstrapState = .idle

Expand Down Expand Up @@ -455,6 +460,9 @@ final class AppBootstrapper: ObservableObject {
shouldSkipAccountRestoration: shouldSkipAccountRestoration
)
} catch {
Self.bootstrapLogger.error(
"constructReadyGraph failed: \(String(describing: error), privacy: .public)"
)
state = .failed(
AppBootstrapFailure(
message: "저장소를 초기화할 수 없어요. 다시 시도해 주세요."
Expand Down
7 changes: 7 additions & 0 deletions Moru/Moru/App/AppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,13 @@ struct AppRouter: View {
case .signedIn = newState {
didCompleteAccountEntry = true
}
if case .signedIn = newState {
// Ask right after login instead of waiting for the user to opt into
// AI routine creation later. A no-op once the user has already
// decided (`requestGeminiDataConsentIfNeeded` only acts on
// `.undecided`), so this never re-prompts someone who already chose.
geminiDataConsentStore.requestGeminiDataConsentIfNeeded()
}
routineSyncRuntimeCoordinator?.accountSessionDidChange()
dependencies.routineTTSWarmupCoordinator?.accountSessionDidChange()
dependencies.serverVoiceCommonAudioProvider?.accountSessionDidChange()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,21 @@ nonisolated private extension AccountProfileResponseDTO {
guard let memberId,
memberId > 0,
memberId == expectedMemberID,
let nickname = try normalizedRequiredText(nickname),
let loginType = try normalizedRequiredText(loginType) else {
throw AccountServerRemoteError.invalidResponse
}

// A member who has never chosen a server voice reports ttsId as null/0.
// That is a legitimate "no selection yet" state, not a malformed response.
// A member who has never chosen a server voice reports ttsId as null/0,
// and one who hasn't set a nickname yet (e.g. a fresh Kakao sign-in with
// no nickname scope granted) reports nickname as null. Both are
// legitimate "not set yet" states, not malformed responses — treating a
// missing nickname as a hard failure was blocking every other
// profile-dependent flow (including automatic TTS voice selection on
// login) for these accounts. Callers already fall back to the local
// display name when nickname is empty (see ProfileView.displayName).
let nickname = (nickname ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)

return ServerAccountProfile(
memberID: memberId,
nickname: nickname,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//

import Foundation
import OSLog

nonisolated enum RoutineSyncResponseDecodingError:
Error,
Expand All @@ -28,6 +29,12 @@ nonisolated final class ProductionRoutineSyncTransport:
Sendable {
private let apiClient: any AccountBoundAPIClient
private let responseDecoder: any RoutineSyncTransportResponseDecoding
/// The server's own error code/message for an unrecognized 409 carries no
/// account or routine content, so it is safe to log verbatim.
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru",
category: "RoutineSyncTransport"
)

init(
apiClient: any AccountBoundAPIClient,
Expand Down Expand Up @@ -89,20 +96,26 @@ nonisolated final class ProductionRoutineSyncTransport:

private static func outcome(for error: APIError) -> RoutineSyncTransportOutcome {
switch error {
case .server(let statusCode, let code, _):
case .server(let statusCode, let code, let message):
if statusCode == 409, code == "COMMON409" {
return .processingConflict
}
if statusCode == 409, code == "COMMON410" {
return .blocked(.idempotencyPayloadConflict)
}
if statusCode == 409 {
logger.notice(
"Routine sync 409 unknownConflict: code=\(code ?? "nil", privacy: .public), message=\(message, privacy: .public)"
)
return .blocked(.unknownConflict)
}
if statusCode == 408 || statusCode == 429
|| (500..<600).contains(statusCode) {
return .ambiguous
}
logger.notice(
"Routine sync definitiveServerRejection: statusCode=\(statusCode), code=\(code ?? "nil", privacy: .public), message=\(message, privacy: .public)"
)
return .blocked(.definitiveServerRejection)

case .transport, .cancelled, .decoding, .missingResult:
Expand Down
57 changes: 51 additions & 6 deletions Moru/Moru/Domain/Services/Voice/RoutineTTSWarmupCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ nonisolated enum RoutineTTSForegroundPreparationStatus: Equatable, Sendable {
nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable {
case cachePlanMissing
case missingGroupBinding
/// No binding AND no createRoutineGroup mutation record exists at all for
/// this local group. Distinguishes "sync never even recorded intent" from
/// the other missingGroupBinding causes below.
case missingGroupBindingNoMutationRecord
/// A createRoutineGroup mutation exists but is in the terminal `.blocked`
/// state, which never resolves automatically.
case missingGroupBindingMutationBlocked
/// A binding record exists but failed identity/shape validation
/// (wrong member, namespace, or remoteID) rather than being absent.
case missingGroupBindingInvalidExistingBinding
case missingRoutineBinding
case remoteFetchFailed
case responseUnavailable
Expand All @@ -99,6 +109,12 @@ nonisolated enum RoutineTTSDiagnosticEvent: String, Sendable {
case cachePurgeFailed
case customCueUnavailable
case serverCueUnavailable
/// The done/remind server-voice common cue's plan was not yet prepared
/// when playback needed it. Fails open (silently completes) by design.
case commonCueUnavailableForServerVoice
/// A common cue's local file was already cache-validated but failed to
/// start playback at cue time. Also fails open by design.
case commonCueLateFailure
}

nonisolated struct RoutineTTSDiagnostics: Sendable {
Expand Down Expand Up @@ -230,6 +246,12 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
private let foregroundPollingPolicy: RoutineTTSForegroundPollingPolicy
private let prefetchPollingPolicy: RoutineTTSPrefetchPollingPolicy
private let diagnostics: RoutineTTSDiagnostics
/// `RoutineSyncBlockReason` carries no account or routine content, so it is
/// safe to log verbatim, unlike the identifier-free `RoutineTTSDiagnostics`.
private let blockReasonLogger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.teammoru.Moru",
category: "RoutineTTSWarmup"
)
private let voiceSelectionVersionStore: any RoutineTTSVoiceSelectionVersionStoring
private let prefetchJobStore: (any RoutineTTSPrefetchJobStoring)?
private weak var backgroundTransferManager:
Expand Down Expand Up @@ -1184,7 +1206,25 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
) {
return .pendingBinding
}
diagnostics.record(.missingGroupBinding)
let mutation = try? bindingRepository.mutation(
memberID: identity.memberID,
operation: .createRoutineGroup,
entityKind: .routineGroup,
localEntityID: routineGroupLocalID
)
if let mutation {
let attemptAgeSeconds = mutation.attempt.map {
Int(Date().timeIntervalSince($0.attemptedAt))
}
blockReasonLogger.notice(
"createRoutineGroup mutation state: \(mutation.state.rawValue, privacy: .public), blockReason: \(mutation.blockReason?.rawValue ?? "nil", privacy: .public), generation: \(mutation.generation, privacy: .public), lastAttemptAgeSeconds: \(attemptAgeSeconds.map(String.init) ?? "nil", privacy: .public)"
)
}
diagnostics.record(
mutation == nil
? .missingGroupBindingNoMutationRecord
: .missingGroupBindingMutationBlocked
)
return .unavailable
}
guard isValidGroupBinding(
Expand All @@ -1196,7 +1236,7 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
routineGroupLocalID: routineGroupLocalID,
routineLocalIDs: requestedRoutineIDs
)
diagnostics.record(.missingGroupBinding)
diagnostics.record(.missingGroupBindingInvalidExistingBinding)
return .unavailable
}
groupBinding = binding
Expand Down Expand Up @@ -1785,11 +1825,16 @@ final class RoutineTTSWarmupCoordinator: RoutineTTSWarming, RoutineTTSLocalAudio
) -> Bool {
switch state {
// Newly saved groups start waiting for runtime contract admission, then
// become queued. Both states can gain a server binding during the same
// bounded first-cue window, so neither should fall through silently.
case .waitingForServerContract, .queued, .attempting:
// become queued. All three states can still gain a server binding during
// the same bounded first-cue window, so none should fall through
// silently. `needsReconciliation` means the request may already have
// reached the server; `RoutineSyncSender` retries it automatically
// (see its `pendingReplay` branch), so it is not a dead end either.
case .waitingForServerContract, .queued, .attempting, .needsReconciliation:
true
case .needsReconciliation, .blocked:
// `blocked` is the only state that requires explicit intervention and
// will never resolve on its own.
case .blocked:
false
}
}
Expand Down
2 changes: 2 additions & 0 deletions Moru/Moru/Platform/TTS/RemoteFirstRoutineGuidancePlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ final class RemoteFirstRoutineGuidancePlayer:
// Fixed server cues are optional and must never block completion-screen
// progression or speech-input restart. Using a bundle here would play a
// different voice than the selected server voice.
diagnostics.record(.commonCueUnavailableForServerVoice)
return .completed
case .localFile(let url):
bundledPlayer.stop()
Expand All @@ -268,6 +269,7 @@ final class RemoteFirstRoutineGuidancePlayer:
case .failedToStart:
// The file was already validated before it entered the cache. A late
// playback failure is still fail-open for a server-voice common cue.
diagnostics.record(.commonCueLateFailure)
return .completed
}
}
Expand Down
Loading
Loading