diff --git a/AGENTS.md b/AGENTS.md index ab10110..0ecd6b5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ writing commit messages that will ship to users. ### Version format -The current source-of-truth version is **2.0.0 (build 83)**. Releases use stable SemVer: +The current source-of-truth version is **2.0.0 (build 86)**. Releases use stable SemVer: | Field | File | Rule | |-------|------|------| diff --git a/CHANGELOG.md b/CHANGELOG.md index a0e281f..4c99ecb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。 ### Fixed +- **Permanent invitation links**: load the account-scoped server invitation profile after sign-in, cache it per account, and keep sharing the exact stable server URL across refreshes and offline failures. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。 - **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。 - **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。 - **Managed AI task routing**: credit-backed requests now preserve dictation, translation, editing, question, clipboard, custom-skill, and agent intent so the gateway can disable costly reasoning for low-latency transforms. / **托管 AI 任务路由**:积分请求现在会保留听写、翻译、编辑、问答、剪贴板、自定义技能与 Agent 意图,让网关可为低延迟转换任务关闭高成本思考。 diff --git a/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift b/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift index 4b63712..48b438c 100644 --- a/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift +++ b/OSGKeyboard/Views/Account/AccountCenterUITestHarness.swift @@ -20,7 +20,8 @@ struct AccountCenterUITestHarness: View { wrappedValue: AccountSessionCoordinator( dependencies: AccountDependencies( sessionService: service, - centerService: service + centerService: service, + referralService: service ), creditStore: store ) @@ -54,7 +55,8 @@ struct ManagedCloudConsentUITestHarness: View { wrappedValue: AccountSessionCoordinator( dependencies: AccountDependencies( sessionService: service, - centerService: service + centerService: service, + referralService: service ), creditStore: AccountCenterUITestCreditStore( accountID: service.account.accountID @@ -80,7 +82,10 @@ struct ManagedCloudConsentUITestHarness: View { } } -private actor AccountCenterUITestService: AccountSessionServicing, AccountCenterServicing { +private actor AccountCenterUITestService: + AccountSessionServicing, + AccountCenterServicing, + ReferralProfileServicing { nonisolated let account = AccountSession( accountID: UUID(uuidString: "00000000-0000-0000-0000-000000000200")!, createdAtEpochSeconds: 1_700_000_000, @@ -106,7 +111,6 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter AccountCenterSnapshot( account: account, credits: AccountCreditSummary(balance: 1_500, usedCredits: 500), - referralProfile: AccountReferralProfile(code: nil, boundCode: nil), referrals: [] ) } @@ -119,14 +123,22 @@ private actor AccountCenterUITestService: AccountSessionServicing, AccountCenter ) } - func createReferralCode() async throws -> String { - "UITestInvite_1234567890" - } - func redeemReferral(code: String) async throws { _ = code } + func loadReferralProfile() async throws -> ReferralProfile { + ReferralProfile( + code: ReferralCode( + code: "UITestInvite_123456789", + inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!, + campaignID: nil, + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + binding: nil + ) + } + func loadCreditProducts() async throws -> [AccountCreditProduct] { [ AccountCreditProduct(productID: "500tks", credits: 500), diff --git a/OSGKeyboard/Views/Account/AccountCenterView.swift b/OSGKeyboard/Views/Account/AccountCenterView.swift index b5ec61d..71784c6 100644 --- a/OSGKeyboard/Views/Account/AccountCenterView.swift +++ b/OSGKeyboard/Views/Account/AccountCenterView.swift @@ -751,10 +751,11 @@ struct AccountInvitationButton: View { @Environment(\.themePalette) private var palette @State private var showsShareDrawer = false - let invitationURL: URL + let invitationURL: URL? var body: some View { Button { + guard invitationURL != nil else { return } showsShareDrawer = true } label: { Label("account.referral.inviteTitle", systemImage: "person.badge.plus") @@ -771,10 +772,14 @@ struct AccountInvitationButton: View { ) } .buttonStyle(.plain) + .disabled(invitationURL == nil) + .accessibilityIdentifier("account.referral.share") .sheet(isPresented: $showsShareDrawer) { - AccountInvitationActivityView(invitationURL: invitationURL) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) + if let invitationURL { + AccountInvitationActivityView(invitationURL: invitationURL) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } } } } @@ -850,7 +855,6 @@ struct IAPReviewScreenshotHarness: View { displayName: "OSG User" ), credits: AccountCreditSummary(balance: 1_000, usedCredits: 0), - referralProfile: AccountReferralProfile(code: nil, boundCode: nil), referrals: [] ) } @@ -860,10 +864,6 @@ private struct IAPReviewAccountService: AccountCenterServicing { throw AccountIntegrationError.unavailable } - func createReferralCode() async throws -> String { - throw AccountIntegrationError.unavailable - } - func redeemReferral(code: String) async throws {} func loadCreditProducts() async throws -> [AccountCreditProduct] { diff --git a/OSGKeyboard/Views/Account/AccountModels.swift b/OSGKeyboard/Views/Account/AccountModels.swift index 76976e4..8c3ab3a 100644 --- a/OSGKeyboard/Views/Account/AccountModels.swift +++ b/OSGKeyboard/Views/Account/AccountModels.swift @@ -75,29 +75,150 @@ struct AccountReferral: Identifiable, Equatable, Sendable { let rewardCredits: Int64? } -struct AccountReferralProfile: Equatable, Sendable { - let code: String? - let boundCode: String? - let inviterRewardCredits: Int64? - let inviteeRewardCredits: Int64? +struct ReferralCode: Codable, Equatable, Sendable { + let code: String + let inviteURL: URL + let campaignID: String? + let createdAt: Date - init( - code: String?, - boundCode: String?, - inviterRewardCredits: Int64? = nil, - inviteeRewardCredits: Int64? = nil - ) { - self.code = code - self.boundCode = boundCode - self.inviterRewardCredits = inviterRewardCredits - self.inviteeRewardCredits = inviteeRewardCredits + private enum CodingKeys: String, CodingKey { + case code + case inviteURL = "inviteUrl" + case campaignID = "campaignId" + case createdAt } + + init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) { + self.code = code + self.inviteURL = inviteURL + self.campaignID = campaignID + self.createdAt = createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let code = try container.decode(String.self, forKey: .code) + let inviteURL = try container.decode(URL.self, forKey: .inviteURL) + let createdAtValue = try container.decode(String.self, forKey: .createdAt) + guard ReferralUniversalLink.isValid(code: code), + inviteURL.scheme?.lowercased() == "https", + inviteURL.host != nil, + let createdAt = Self.date(from: createdAtValue) else { + throw DecodingError.dataCorrupted( + .init( + codingPath: container.codingPath, + debugDescription: "Invalid referral code, invite URL, or creation date." + ) + ) + } + self.code = code + self.inviteURL = inviteURL + campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID) + self.createdAt = createdAt + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(code, forKey: .code) + try container.encode(inviteURL, forKey: .inviteURL) + try container.encodeIfPresent(campaignID, forKey: .campaignID) + try container.encode(Self.string(from: createdAt), forKey: .createdAt) + } + + private static func date(from value: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let standard = ISO8601DateFormatter() + standard.formatOptions = [.withInternetDateTime] + return fractional.date(from: value) ?? standard.date(from: value) + } + + private static func string(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: date) + } +} + +/// The binding payload can gain server-owned fields without breaking cached +/// profiles. The client only needs to preserve it; invitation sharing uses code. +struct ReferralBinding: Codable, Equatable, Sendable { + let fields: [String: ReferralJSONValue] + + init(from decoder: Decoder) throws { + fields = try decoder.singleValueContainer().decode( + [String: ReferralJSONValue].self + ) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(fields) + } +} + +enum ReferralJSONValue: Codable, Equatable, Sendable { + case string(String) + case integer(Int64) + case number(Double) + case boolean(Bool) + case object([String: ReferralJSONValue]) + case array([ReferralJSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .boolean(value) + } else if let value = try? container.decode(Int64.self) { + self = .integer(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([String: ReferralJSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([ReferralJSONValue].self) { + self = .array(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported referral binding value." + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .integer(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .boolean(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } +} + +struct ReferralProfile: Codable, Equatable, Sendable { + let code: ReferralCode + let binding: ReferralBinding? } struct AccountCenterSnapshot: Equatable, Sendable { let account: AccountSession let credits: AccountCreditSummary - let referralProfile: AccountReferralProfile let referrals: [AccountReferral] } @@ -158,7 +279,6 @@ protocol AccountCenterServicing: Sendable { cachedSnapshot: AccountCenterSnapshot? ) async throws -> AccountCenterSnapshot func updateDisplayName(_ displayName: String) async throws -> AccountSession - func createReferralCode() async throws -> String func redeemReferral(code: String) async throws func loadCreditProducts() async throws -> [AccountCreditProduct] func submitCreditTransaction(_ signedTransaction: String) async throws -> AccountCreditPurchase @@ -195,13 +315,32 @@ extension AccountCenterServicing { } } +protocol ReferralProfileServicing: Sendable { + func loadReferralProfile() async throws -> ReferralProfile +} + struct AccountDependencies: Sendable { let sessionService: any AccountSessionServicing let centerService: any AccountCenterServicing + let referralService: any ReferralProfileServicing + + init( + sessionService: any AccountSessionServicing, + centerService: any AccountCenterServicing, + referralService: (any ReferralProfileServicing)? = nil + ) { + self.sessionService = sessionService + self.centerService = centerService + self.referralService = referralService ?? UnavailableReferralProfileService() + } static let unavailable: AccountDependencies = { let service = UnavailableAccountService() - return AccountDependencies(sessionService: service, centerService: service) + return AccountDependencies( + sessionService: service, + centerService: service, + referralService: UnavailableReferralProfileService() + ) }() } @@ -238,10 +377,6 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter throw AccountIntegrationError.unavailable } - func createReferralCode() async throws -> String { - throw AccountIntegrationError.unavailable - } - func redeemReferral(code: String) async throws { throw AccountIntegrationError.unavailable } @@ -255,6 +390,12 @@ private struct UnavailableAccountService: AccountSessionServicing, AccountCenter } } +private struct UnavailableReferralProfileService: ReferralProfileServicing { + func loadReferralProfile() async throws -> ReferralProfile { + throw AccountIntegrationError.unavailable + } +} + enum ReferralUniversalLink { static let host = "osglab.com" static let codeLength = 22 @@ -285,10 +426,6 @@ enum ReferralUniversalLink { } } - static func invitationURL(for code: String) -> URL? { - guard isValid(code: code) else { return nil } - return URL(string: "https://\(host)/i/\(code)") - } } @MainActor @@ -326,3 +463,43 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring { defaults.removeObject(forKey: Self.storageKey) } } + +@MainActor +protocol ReferralProfileStoring: AnyObject { + func profile(for accountID: UUID) -> ReferralProfile? + func save(_ profile: ReferralProfile, for accountID: UUID) + func removeProfile(for accountID: UUID) +} + +@MainActor +final class UserDefaultsReferralProfileStore: ReferralProfileStoring { + private static let storageKeyPrefix = "account.referralProfile.v1." + + private let defaults: UserDefaults + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + func profile(for accountID: UUID) -> ReferralProfile? { + guard let data = defaults.data(forKey: storageKey(for: accountID)) else { + return nil + } + return try? decoder.decode(ReferralProfile.self, from: data) + } + + func save(_ profile: ReferralProfile, for accountID: UUID) { + guard let data = try? encoder.encode(profile) else { return } + defaults.set(data, forKey: storageKey(for: accountID)) + } + + func removeProfile(for accountID: UUID) { + defaults.removeObject(forKey: storageKey(for: accountID)) + } + + private func storageKey(for accountID: UUID) -> String { + Self.storageKeyPrefix + accountID.uuidString.lowercased() + } +} diff --git a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift index 748e696..df1e2ea 100644 --- a/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift +++ b/OSGKeyboard/Views/Account/AccountSessionCoordinator.swift @@ -43,6 +43,7 @@ final class AccountSessionCoordinator: ObservableObject { @Published private(set) var accountRefreshErrorKey: String? let creditPurchases: AccountCreditPurchaseManager + let referralProfile: ReferralProfileViewModel private let sessionService: any AccountSessionServicing private let centerService: any AccountCenterServicing @@ -60,6 +61,8 @@ final class AccountSessionCoordinator: ObservableObject { creditStore: any AccountCreditStore = LiveAccountCreditStore(), pendingReferralStore: any PendingReferralCodeStoring = UserDefaultsPendingReferralCodeStore(), + referralProfileStore: any ReferralProfileStoring = + UserDefaultsReferralProfileStore(), accountRefreshInterval: TimeInterval = 10 * 60, now: @escaping () -> Date = Date.init, analyticsClient: any AnalyticsClient = NoopAnalyticsClient(), @@ -73,6 +76,10 @@ final class AccountSessionCoordinator: ObservableObject { store: creditStore, analyticsClient: analyticsClient ) + referralProfile = ReferralProfileViewModel( + service: dependencies.referralService, + store: referralProfileStore + ) self.pendingReferralStore = pendingReferralStore self.analyticsClient = analyticsClient self.onAccountAuthenticated = onAccountAuthenticated @@ -107,6 +114,7 @@ final class AccountSessionCoordinator: ObservableObject { } await onAccountAuthenticated(session.accountID) sessionPhase = .signedIn(session) + referralProfile.startSession(accountID: session.accountID) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) } catch { @@ -153,6 +161,7 @@ final class AccountSessionCoordinator: ObservableObject { let session = try await sessionService.signIn(with: payload) await onAccountAuthenticated(session.accountID) sessionPhase = .signedIn(session) + referralProfile.startSession(accountID: session.accountID) await redeemPendingReferralIfNeeded() await refreshAccountData(force: true) } catch { @@ -210,7 +219,6 @@ final class AccountSessionCoordinator: ObservableObject { AccountCenterSnapshot( account: account, credits: snapshot.credits, - referralProfile: snapshot.referralProfile, referrals: snapshot.referrals ) ) @@ -251,11 +259,13 @@ final class AccountSessionCoordinator: ObservableObject { operation = .signingOut operationErrorKey = nil defer { operation = nil } + referralProfile.cancelRefresh() do { try await sessionService.signOut() creditPurchases.reset() clearAccountRefreshState() + referralProfile.endSession() sessionPhase = .signedOut snapshotPhase = .idle } catch { @@ -271,12 +281,14 @@ final class AccountSessionCoordinator: ObservableObject { operation = .deletingAccount operationErrorKey = nil defer { operation = nil } + referralProfile.cancelRefresh() do { try await sessionService.deleteAccount(with: payload) await onAccountDeleted() creditPurchases.reset() clearAccountRefreshState() + referralProfile.endSession(removeCache: true) sessionPhase = .signedOut snapshotPhase = .idle pendingReferralStore.clear() diff --git a/OSGKeyboard/Views/Account/LiveAccountServices.swift b/OSGKeyboard/Views/Account/LiveAccountServices.swift index 91fe84b..5496d0e 100644 --- a/OSGKeyboard/Views/Account/LiveAccountServices.swift +++ b/OSGKeyboard/Views/Account/LiveAccountServices.swift @@ -38,7 +38,11 @@ enum LiveAccountDependencyFactory { grantStore: grantStore, configuration: AppGroupStore() ) - return AccountDependencies(sessionService: service, centerService: service) + return AccountDependencies( + sessionService: service, + centerService: service, + referralService: LiveReferralProfileService(apiClient: apiClient) + ) } } @@ -55,21 +59,11 @@ actor AccountCenterSnapshotLoader { ) async throws -> AccountCenterSnapshot { async let account = apiClient.account() async let balance = resource(CreditBalanceDTO.self, .creditsBalance) - async let profile = optionalResource( - ReferralProfileDTO.self, - .referralProfile, - diagnosticName: "referral-profile" - ) async let referrals = optionalResource( [ReferralBindingDTO].self, .referrals(limit: 50), diagnosticName: "referrals" ) - async let campaigns = optionalResource( - [ReferralCampaignDTO].self, - .referralCampaigns, - diagnosticName: "referral-campaigns" - ) let core: (OSGAccount, CreditBalanceDTO) do { @@ -81,28 +75,7 @@ actor AccountCenterSnapshotLoader { ) throw error } - let optionalValues = await (profile, referrals, campaigns) - let invitationCode = await resolvedInvitationCode( - profile: optionalValues.0, - cachedProfile: cachedSnapshot?.referralProfile - ) - let campaign = optionalValues.2?.first { - $0.id == optionalValues.0?.code?.campaignId - } ?? optionalValues.2?.first - let referralProfile = if optionalValues.0 == nil, - let cachedProfile = cachedSnapshot?.referralProfile { - cachedProfile - } else { - AccountReferralProfile( - code: invitationCode, - boundCode: cachedSnapshot?.referralProfile.boundCode, - inviterRewardCredits: campaign?.inviterRewardCredits - ?? cachedSnapshot?.referralProfile.inviterRewardCredits, - inviteeRewardCredits: campaign?.inviteeRewardCredits - ?? cachedSnapshot?.referralProfile.inviteeRewardCredits - ) - } - let loadedReferrals = optionalValues.1?.map { + let loadedReferrals = await referrals?.map { AccountReferral( id: UUID(), status: Self.referralStatus($0.rewardStatus), @@ -121,7 +94,6 @@ actor AccountCenterSnapshotLoader { balance: core.1.balance, usedCredits: core.1.lifetimeUsed ?? 0 ), - referralProfile: referralProfile, referrals: loadedReferrals ) } @@ -151,26 +123,6 @@ actor AccountCenterSnapshotLoader { } } - private func resolvedInvitationCode( - profile: ReferralProfileDTO?, - cachedProfile: AccountReferralProfile? - ) async -> String? { - if let code = profile?.code?.code ?? cachedProfile?.code { - return code - } - do { - let data = try await apiClient.authorizedResourceData(.createReferralCode) - return try decoder.decode(ReferralCodeDTO.self, from: data).code - } catch { - OSGDiag.log( - "account refresh optional=referral-code fallback=cache " - + "error=\(AccountDiagnostic.code(for: error))", - category: "account" - ) - return cachedProfile?.code - } - } - private static func referralStatus(_ value: String) -> AccountReferralStatus { switch value.uppercased() { case "REWARDED": @@ -192,6 +144,24 @@ actor AccountCenterSnapshotLoader { } } +actor LiveReferralProfileService: ReferralProfileServicing { + private let apiClient: AccountAPIClient + private let decoder = JSONDecoder() + + init(apiClient: AccountAPIClient) { + self.apiClient = apiClient + } + + func loadReferralProfile() async throws -> ReferralProfile { + let data = try await apiClient.authorizedResourceData(.referralProfile) + do { + return try decoder.decode(ReferralProfile.self, from: data) + } catch { + throw AccountAPIError.decoding + } + } +} + private actor LiveAccountService: AccountSessionServicing, AccountCenterServicing { private let apiClient: AccountAPIClient private let accountCenterLoader: AccountCenterSnapshotLoader @@ -356,11 +326,6 @@ private actor LiveAccountService: AccountSessionServicing, AccountCenterServicin ) } - func createReferralCode() async throws -> String { - let data = try await apiClient.authorizedResourceData(.createReferralCode) - return try decoder.decode(ReferralCodeDTO.self, from: data).code - } - func redeemReferral(code: String) async throws { struct Request: Encodable { let code: String } let body = try encoder.encode(Request(code: code)) @@ -586,21 +551,6 @@ private struct CreditBalanceDTO: Decodable, Sendable { let lifetimeUsed: Int64? } -private struct ReferralCodeDTO: Decodable, Sendable { - let code: String - let campaignId: String? -} - -private struct ReferralProfileDTO: Decodable, Sendable { - let code: ReferralCodeDTO? -} - -private struct ReferralCampaignDTO: Decodable, Sendable { - let id: String - let inviterRewardCredits: Int64 - let inviteeRewardCredits: Int64 -} - private struct ReferralBindingDTO: Decodable, Sendable { let boundAt: String let rewardStatus: String diff --git a/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift b/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift new file mode 100644 index 0000000..2f3920c --- /dev/null +++ b/OSGKeyboard/Views/Account/ReferralProfileViewModel.swift @@ -0,0 +1,197 @@ +// ReferralProfileViewModel.swift +// OSGKeyboard · Main App +// +// Account-scoped invitation state. The server profile is authoritative while +// the local cache keeps the permanent invitation link visible when offline. + +import Foundation +import OSGKeyboardHostSupport + +@MainActor +final class ReferralProfileViewModel: ObservableObject { + enum State: Equatable { + case idle + case loading + case loaded(ReferralProfile) + case failed(messageKey: String) + } + + @Published private(set) var state: State = .idle + @Published private(set) var isRefreshing = false + @Published private(set) var refreshErrorKey: String? + + private let service: any ReferralProfileServicing + private let store: any ReferralProfileStoring + + private var accountID: UUID? + private var requestID: UUID? + private var refreshTask: Task? + + init( + service: any ReferralProfileServicing, + store: any ReferralProfileStoring = UserDefaultsReferralProfileStore() + ) { + self.service = service + self.store = store + } + + deinit { + refreshTask?.cancel() + } + + /// Starts one automatic load for this signed-in account session. Repeated + /// view appearances are ignored; ending or changing the session cancels it. + func startSession(accountID: UUID) { + guard self.accountID != accountID else { return } + + cancelCurrentRequest() + self.accountID = accountID + refreshErrorKey = nil + if let cachedProfile = store.profile(for: accountID) { + state = .loaded(cachedProfile) + } else { + state = .loading + } + beginRefresh() + } + + func endSession(removeCache: Bool = false) { + let previousAccountID = accountID + cancelCurrentRequest() + accountID = nil + state = .idle + refreshErrorKey = nil + if removeCache, let previousAccountID { + store.removeProfile(for: previousAccountID) + } + } + + func cancelRefresh() { + guard let accountID else { return } + cancelCurrentRequest() + if case .loaded = state { + // Preserve the visible server profile. + } else if let cachedProfile = store.profile(for: accountID) { + state = .loaded(cachedProfile) + } else { + state = .idle + } + refreshErrorKey = nil + } + + func refresh() async { + guard accountID != nil else { + state = .idle + return + } + if let refreshTask { + await refreshTask.value + return + } + beginRefresh() + await refreshTask?.value + } + + private func beginRefresh() { + guard refreshTask == nil, let accountID else { return } + + let id = UUID() + requestID = id + refreshErrorKey = nil + isRefreshing = true + if case .loaded = state { + // Keep a cached or previously loaded profile visible while refreshing. + } else { + state = .loading + } + + refreshTask = Task { @MainActor [weak self] in + await self?.performRefresh(accountID: accountID, requestID: id) + } + } + + private func performRefresh(accountID: UUID, requestID: UUID) async { + let previousProfile: ReferralProfile? = if case let .loaded(profile) = state { + profile + } else { + nil + } + + defer { + if self.requestID == requestID { + isRefreshing = false + refreshTask = nil + self.requestID = nil + } + } + + do { + try Task.checkCancellation() + let profile = try await service.loadReferralProfile() + try Task.checkCancellation() + guard self.accountID == accountID, self.requestID == requestID else { + return + } + store.save(profile, for: accountID) + state = .loaded(profile) + refreshErrorKey = nil + } catch is CancellationError { + guard self.accountID == accountID, self.requestID == requestID else { + return + } + state = previousProfile.map(State.loaded) ?? .idle + refreshErrorKey = nil + } catch { + guard self.accountID == accountID, self.requestID == requestID else { + return + } + let messageKey = Self.errorMessageKey(for: error) + refreshErrorKey = messageKey + if let previousProfile { + state = .loaded(previousProfile) + } else { + state = .failed(messageKey: messageKey) + } + } + } + + private func cancelCurrentRequest() { + refreshTask?.cancel() + refreshTask = nil + requestID = nil + isRefreshing = false + } + + private static func errorMessageKey(for error: Error) -> String { + guard let error = error as? AccountAPIError else { + return error as? AccountIntegrationError == .unavailable + ? "account.error.unavailable" + : "account.referral.error.load" + } + switch error { + case .unauthorized, .refreshTokenReuse, .sessionUnavailable: + return "account.referral.error.session" + case .transport: + return "account.referral.error.network" + case .externalServiceUnavailable: + return "account.referral.error.unavailable" + case .conflict: + return "account.referral.error.conflict" + case .server(let statusCode, _, _): + switch statusCode { + case 401: + return "account.referral.error.session" + case 404: + return "account.referral.error.notFound" + case 409: + return "account.referral.error.conflict" + case 500..<600: + return "account.referral.error.unavailable" + default: + return "account.referral.error.load" + } + default: + return "account.referral.error.load" + } + } +} diff --git a/OSGKeyboard/Views/SettingsView.swift b/OSGKeyboard/Views/SettingsView.swift index 9a4991c..7d440fe 100644 --- a/OSGKeyboard/Views/SettingsView.swift +++ b/OSGKeyboard/Views/SettingsView.swift @@ -230,55 +230,50 @@ struct SettingsView: View { } private var accountRewardsEntrySection: some View { - Group { + VStack(spacing: 0) { switch accountSession.snapshotPhase { case let .loaded(snapshot): let totalCredits = creditTotal(snapshot) - VStack(alignment: .leading, spacing: Spacing.sm) { - Button { - path.append(SettingsRoute.account) - } label: { - VStack(alignment: .leading, spacing: Spacing.sm) { - HStack(alignment: .top, spacing: Spacing.md) { - VStack(alignment: .leading, spacing: Spacing.xxs) { - Text(creditRemainingText(snapshot.credits.balance)) - .font(TypeStyle.title3.monospacedDigit()) - .foregroundStyle(palette.textPrimary) - Text("account.credits.balance") - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - } - - Spacer(minLength: Spacing.xs) - - VStack(alignment: .trailing, spacing: Spacing.xxs) { - Text(creditUsedText(snapshot.credits.usedCredits)) - Text(creditTotalText(totalCredits)) - } - .font(TypeStyle.caption) - .monospacedDigit() - .foregroundStyle(palette.textSecondary) - - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(palette.textTertiary) - .frame(minWidth: 32, minHeight: 32) - .accessibilityHidden(true) + Button { + path.append(SettingsRoute.account) + } label: { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(alignment: .top, spacing: Spacing.md) { + VStack(alignment: .leading, spacing: Spacing.xxs) { + Text(creditRemainingText(snapshot.credits.balance)) + .font(TypeStyle.title3.monospacedDigit()) + .foregroundStyle(palette.textPrimary) + Text("account.credits.balance") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) } - AccountCreditProgress( - remaining: snapshot.credits.balance, - used: snapshot.credits.usedCredits, - showsLabels: false - ) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + Spacer(minLength: Spacing.xs) - Divider().background(palette.divider) - invitationLink(snapshot.referralProfile) + VStack(alignment: .trailing, spacing: Spacing.xxs) { + Text(creditUsedText(snapshot.credits.usedCredits)) + Text(creditTotalText(totalCredits)) + } + .font(TypeStyle.caption) + .monospacedDigit() + .foregroundStyle(palette.textSecondary) + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(palette.textTertiary) + .frame(minWidth: 32, minHeight: 32) + .accessibilityHidden(true) + } + + AccountCreditProgress( + remaining: snapshot.credits.balance, + used: snapshot.credits.usedCredits, + showsLabels: false + ) + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) .padding(Spacing.md) case .failed: VStack(alignment: .leading, spacing: Spacing.sm) { @@ -302,39 +297,13 @@ struct SettingsView: View { } .settingsListRow() } + + Divider().background(palette.divider) + AccountReferralLinkView(viewModel: accountSession.referralProfile) } .surfaceCard() } - @ViewBuilder - private func invitationLink(_ profile: AccountReferralProfile) -> some View { - if let code = profile.code, - let invitationURL = ReferralUniversalLink.invitationURL(for: code) { - HStack(spacing: Spacing.md) { - Text( - AppL10n.string( - "account.referral.equalRewardDescription", - language: config.uiLanguage - ) - ) - .font(TypeStyle.caption) - .foregroundStyle(palette.textSecondary) - .fixedSize(horizontal: false, vertical: true) - Spacer(minLength: Spacing.xs) - AccountInvitationButton(invitationURL: invitationURL) - } - } else { - HStack(spacing: Spacing.sm) { - ProgressView() - .controlSize(.small) - .tint(palette.accent) - Text("account.referral.preparingLink") - .font(TypeStyle.footnote) - .foregroundStyle(palette.textSecondary) - } - } - } - private func creditTotal(_ snapshot: AccountCenterSnapshot) -> Int64 { let remaining = max(snapshot.credits.balance, 0) let used = max(snapshot.credits.usedCredits, 0) @@ -493,6 +462,81 @@ struct SettingsView: View { } } +private struct AccountReferralLinkView: View { + @Environment(\.themePalette) private var palette + @ObservedObject var viewModel: ReferralProfileViewModel + + var body: some View { + Group { + switch viewModel.state { + case .idle: + retryRow( + messageKey: "account.referral.loadLink", + actionKey: "account.referral.loadLink" + ) + case .loading: + HStack(spacing: Spacing.sm) { + ProgressView() + .controlSize(.small) + .tint(palette.accent) + Text("account.referral.loadingLink") + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + } + case .failed(let messageKey): + retryRow(messageKey: messageKey, actionKey: "account.retry") + case .loaded(let profile): + loadedContent(profile) + } + } + .padding(Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("account.referral.profile") + } + + private func loadedContent(_ profile: ReferralProfile) -> some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(spacing: Spacing.md) { + Text("account.referral.equalRewardDescription") + .font(TypeStyle.caption) + .foregroundStyle(palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: Spacing.xs) + AccountInvitationButton(invitationURL: profile.code.inviteURL) + } + + if viewModel.isRefreshing { + HStack(spacing: Spacing.xs) { + ProgressView() + .controlSize(.mini) + Text("account.referral.refreshingLink") + } + .font(TypeStyle.caption) + .foregroundStyle(palette.textTertiary) + } else if let refreshErrorKey = viewModel.refreshErrorKey { + retryRow(messageKey: refreshErrorKey, actionKey: "account.retry") + } + } + } + + private func retryRow( + messageKey: String, + actionKey: LocalizedStringKey + ) -> some View { + HStack(spacing: Spacing.sm) { + Text(LocalizedStringKey(messageKey)) + .font(TypeStyle.footnote) + .foregroundStyle(palette.textSecondary) + Spacer(minLength: Spacing.xs) + Button(actionKey) { + Task { await viewModel.refresh() } + } + .font(TypeStyle.bodyEmph) + .foregroundStyle(palette.accent) + } + } +} + // MARK: - Sheet dismiss (isolated from Settings root) private struct SettingsSheetDismissButton: View { diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings index 9476887..e747a11 100644 --- a/OSGKeyboard/en.lproj/Localizable.strings +++ b/OSGKeyboard/en.lproj/Localizable.strings @@ -824,8 +824,17 @@ "account.referral.equalRewardDescription" = "For each successful invitation, you and your friend each receive 1,000 credits."; "account.referral.equalRewardGeneric" = "For each successful invitation, both of you receive credits."; "account.referral.summaryCompact" = "Rewarded %d · Pending %d"; -"account.referral.preparingLink" = "Preparing your invitation link…"; -"account.referral.createCode" = "Create invitation code"; +"account.referral.preparingLink" = "Loading your invitation link…"; +"account.referral.createCode" = "Load invitation link"; +"account.referral.loadLink" = "Load invitation link"; +"account.referral.loadingLink" = "Loading your invitation link…"; +"account.referral.refreshingLink" = "Refreshing invitation link…"; +"account.referral.error.load" = "Couldn’t load your invitation link."; +"account.referral.error.session" = "Sign in again to load your invitation link."; +"account.referral.error.network" = "Check your connection and try again."; +"account.referral.error.notFound" = "Your invitation link isn’t available yet."; +"account.referral.error.conflict" = "Your invitation link is temporarily unavailable."; +"account.referral.error.unavailable" = "The invitation service is temporarily unavailable."; "account.referral.status.pending" = "Pending"; "account.referral.status.rewarded" = "Rewarded"; "account.referral.status.ineligible" = "Ineligible"; diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings index eb3aeee..db3a3bd 100644 --- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings +++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings @@ -823,8 +823,17 @@ "account.referral.equalRewardDescription" = "每成功邀请一位好友,你和好友各获得 1000 积分。"; "account.referral.equalRewardGeneric" = "每成功邀请一位好友,双方均可获得积分。"; "account.referral.summaryCompact" = "已奖励 %d · 待确认 %d"; -"account.referral.preparingLink" = "正在生成邀请链接…"; -"account.referral.createCode" = "生成邀请码"; +"account.referral.preparingLink" = "正在加载邀请链接…"; +"account.referral.createCode" = "加载邀请链接"; +"account.referral.loadLink" = "加载邀请链接"; +"account.referral.loadingLink" = "正在加载邀请链接…"; +"account.referral.refreshingLink" = "正在刷新邀请链接…"; +"account.referral.error.load" = "无法加载邀请链接。"; +"account.referral.error.session" = "请重新登录后加载邀请链接。"; +"account.referral.error.network" = "请检查网络连接后重试。"; +"account.referral.error.notFound" = "邀请链接暂不可用。"; +"account.referral.error.conflict" = "邀请链接暂时不可用。"; +"account.referral.error.unavailable" = "邀请服务暂时不可用。"; "account.referral.status.pending" = "待确认"; "account.referral.status.rewarded" = "已奖励"; "account.referral.status.ineligible" = "不符合条件"; diff --git a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift index 5fb2ace..ef59e38 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountAPIClient.swift @@ -485,7 +485,7 @@ public actor AccountAPIClient { } var request = URLRequest(url: url) request.httpMethod = resource.method - request.timeoutInterval = 30 + request.timeoutInterval = resource == .referralProfile ? 15 : 30 request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") if resource == .creditsBalance { @@ -504,6 +504,10 @@ public actor AccountAPIClient { private func send(_ request: URLRequest) async throws -> (data: Data, response: HTTPURLResponse) { do { return try await transport.data(for: request) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() } catch let error as AccountAPIError { throw error } catch { diff --git a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift index 662e931..ddef187 100644 --- a/OSGKeyboardHostSupport/Features/Account/AccountModels.swift +++ b/OSGKeyboardHostSupport/Features/Account/AccountModels.swift @@ -54,7 +54,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable { case referralProfile case referralCampaigns case referrals(limit: Int) - case createReferralCode case redeemReferral case storeKitProducts case storeKitTransactions(limit: Int, cursor: String?) @@ -63,7 +62,7 @@ public enum AccountAuthorizedResource: Sendable, Equatable { var method: String { switch self { - case .createReferralCode, .redeemReferral, .submitStoreKitTransaction: + case .redeemReferral, .submitStoreKitTransaction: return "POST" case .revokeGatewayGrant: return "DELETE" @@ -82,8 +81,6 @@ public enum AccountAuthorizedResource: Sendable, Equatable { return "/v1/referrals/campaigns" case .referrals(let limit): return "/v1/referrals?limit=\(min(max(limit, 1), 100))" - case .createReferralCode: - return "/v1/referrals/code" case .redeemReferral: return "/v1/referrals/redeem" case .storeKitProducts: diff --git a/OSGKeyboardTests/AccountCenterViewModelTests.swift b/OSGKeyboardTests/AccountCenterViewModelTests.swift index 1867e6c..fff039f 100644 --- a/OSGKeyboardTests/AccountCenterViewModelTests.swift +++ b/OSGKeyboardTests/AccountCenterViewModelTests.swift @@ -163,10 +163,6 @@ final class AccountCenterViewModelTests: XCTestCase { let latest = AccountCenterSnapshot( account: account, credits: latestCredits, - referralProfile: AccountReferralProfile( - code: "LatestCode_1234567890", - boundCode: nil - ), referrals: [ AccountReferral( id: UUID(), @@ -213,7 +209,6 @@ final class AccountCenterViewModelTests: XCTestCase { let latest = AccountCenterSnapshot( account: account, credits: AccountCreditSummary(balance: 500, usedCredits: 500), - referralProfile: original.referralProfile, referrals: original.referrals ) let service = AccountServiceSpy( @@ -443,7 +438,6 @@ final class AccountCenterViewModelTests: XCTestCase { balance: 9_223_372_036_854_775_000, usedCredits: 1_234 ), - referralProfile: AccountReferralProfile(code: nil, boundCode: nil), referrals: [] ) } @@ -551,10 +545,6 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing return centerSnapshot } - func createReferralCode() async throws -> String { - "CreatedCode_1234567890" - } - func redeemReferral(code: String) async throws { if shouldFailRedemption { throw AccountServiceSpyError.failed diff --git a/OSGKeyboardTests/AccountCreditPurchaseManagerTests.swift b/OSGKeyboardTests/AccountCreditPurchaseManagerTests.swift index b43cc6a..6171ac5 100644 --- a/OSGKeyboardTests/AccountCreditPurchaseManagerTests.swift +++ b/OSGKeyboardTests/AccountCreditPurchaseManagerTests.swift @@ -200,10 +200,6 @@ private actor CreditServiceStub: AccountCenterServicing { throw AccountIntegrationError.unavailable } - func createReferralCode() async throws -> String { - throw AccountIntegrationError.unavailable - } - func redeemReferral(code: String) async throws { throw AccountIntegrationError.unavailable } diff --git a/OSGKeyboardTests/AccountSnapshotLoaderTests.swift b/OSGKeyboardTests/AccountSnapshotLoaderTests.swift index e3edc2f..68e857d 100644 --- a/OSGKeyboardTests/AccountSnapshotLoaderTests.swift +++ b/OSGKeyboardTests/AccountSnapshotLoaderTests.swift @@ -10,7 +10,7 @@ import XCTest final class AccountSnapshotLoaderTests: XCTestCase { @MainActor - func testOptionalReferralFailuresUseCachedAccountData() async throws { + func testOptionalReferralListFailureUsesCachedAccountData() async throws { let account = OSGKeyboard.AccountSession( accountID: UUID(), createdAtEpochSeconds: 1_700_000_000 @@ -24,12 +24,6 @@ final class AccountSnapshotLoaderTests: XCTestCase { let cached = AccountCenterSnapshot( account: account, credits: AccountCreditSummary(balance: 1_000, usedCredits: 0), - referralProfile: AccountReferralProfile( - code: "CachedCode_1234567890", - boundCode: nil, - inviterRewardCredits: 1_000, - inviteeRewardCredits: 1_000 - ), referrals: [cachedReferral] ) let transport = AccountCenterRoutingTransport(accountID: account.accountID) @@ -48,12 +42,15 @@ final class AccountSnapshotLoaderTests: XCTestCase { refreshed.credits, AccountCreditSummary(balance: 750, usedCredits: 250) ) - XCTAssertEqual(refreshed.referralProfile, cached.referralProfile) XCTAssertEqual(refreshed.referrals, cached.referrals) let requests = await transport.requests XCTAssertEqual( requests.count { $0.url?.path == "/v1/referrals/me" }, - 2 + 0 + ) + XCTAssertEqual( + requests.count { $0.url?.path == "/v1/referrals/code" }, + 0 ) XCTAssertEqual( requests.count { $0.url?.path == "/v1/referrals" }, @@ -88,7 +85,7 @@ private actor AccountCenterRoutingTransport: AccountHTTPTransport { statusCode: 200, body: Data(#"{"balance":750,"lifetimeUsed":250}"#.utf8) ) - case "/v1/referrals/me", "/v1/referrals": + case "/v1/referrals": response = .init( statusCode: 503, body: apiErrorData( diff --git a/OSGKeyboardTests/ReferralProfileTests.swift b/OSGKeyboardTests/ReferralProfileTests.swift new file mode 100644 index 0000000..dc61376 --- /dev/null +++ b/OSGKeyboardTests/ReferralProfileTests.swift @@ -0,0 +1,528 @@ +// ReferralProfileTests.swift +// OSGKeyboardTests +// +// Server-owned invitation decoding, transport, cache, and state-machine coverage. + +@testable import OSGKeyboard +@testable import OSGKeyboardHostSupport +import XCTest + +final class ReferralProfileTests: XCTestCase { + private let accountA = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")! + private let accountB = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")! + + func testReferralProfileDecodesServerInviteURLAndOptionalBinding() throws { + let inviteURL = "https://osglab.com/i/Abcdefghij_1234567890-?source=server" + let data = Data( + """ + { + "code": { + "code": "Abcdefghij_1234567890-", + "inviteUrl": "\(inviteURL)", + "campaignId": null, + "createdAt": "2026-08-20T12:34:56.123Z" + }, + "binding": { + "boundAt": "2026-08-20T12:35:00Z", + "rewardStatus": "PENDING", + "serverOwnedField": 42 + } + } + """.utf8 + ) + + let profile = try JSONDecoder().decode(ReferralProfile.self, from: data) + + XCTAssertEqual(profile.code.code, "Abcdefghij_1234567890-") + XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL) + XCTAssertNil(profile.code.campaignID) + XCTAssertNotNil(profile.binding) + } + + func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws { + let session = makeAccountSession() + let body = referralProfileData(inviteSuffix: "?source=transport") + let transport = QueueAccountTransport([ + .init(statusCode: 200, body: body) + ]) + let client = AccountAPIClient( + baseURL: URL(string: "https://account.test")!, + transport: transport, + sessionVault: InMemoryAccountSecurityStore(session: session) + ) + let service = LiveReferralProfileService(apiClient: client) + + let profile = try await service.loadReferralProfile() + + XCTAssertEqual( + profile.code.inviteURL.absoluteString, + "https://osglab.com/i/Abcdefghij_1234567890-?source=transport" + ) + let requests = await transport.requests + let request = try XCTUnwrap(requests.single) + XCTAssertEqual(request.url?.path, "/v1/referrals/me") + XCTAssertEqual(request.httpMethod, "GET") + XCTAssertEqual(request.timeoutInterval, 15) + XCTAssertEqual( + request.value(forHTTPHeaderField: "Authorization"), + "Bearer \(session.accessToken)" + ) + XCTAssertFalse(requests.contains { $0.url?.path == "/v1/referrals/code" }) + } + + func testReferralServiceDecodesLegacyStringErrorResponse() async { + let transport = QueueAccountTransport([ + .init(statusCode: 404, body: Data(#"{"error":"missing"}"#.utf8)) + ]) + let client = AccountAPIClient( + baseURL: URL(string: "https://account.test")!, + transport: transport, + sessionVault: InMemoryAccountSecurityStore(session: makeAccountSession()) + ) + let service = LiveReferralProfileService(apiClient: client) + + do { + _ = try await service.loadReferralProfile() + XCTFail("Expected a not-found error.") + } catch let error as AccountAPIError { + XCTAssertEqual( + error, + .server(statusCode: 404, code: "http_error", message: "missing") + ) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + @MainActor + func testSuccessfulRefreshAlwaysExitsLoading() async { + let profile = makeProfile(inviteSuffix: "?version=fresh") + let service = ReferralProfileServiceSpy(profiles: [profile]) + let viewModel = makeViewModel(service: service) + + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + + XCTAssertEqual(viewModel.state, .loaded(profile)) + XCTAssertFalse(viewModel.isRefreshing) + XCTAssertNil(viewModel.refreshErrorKey) + } + + @MainActor + func testFailedRefreshAlwaysExitsLoadingAndShowsRetryState() async { + let service = ReferralProfileServiceSpy(error: .transport) + let viewModel = makeViewModel(service: service) + + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + + XCTAssertEqual( + viewModel.state, + .failed(messageKey: "account.referral.error.network") + ) + XCTAssertFalse(viewModel.isRefreshing) + } + + @MainActor + func testManualRetryLoadsProfileAfterInitialFailure() async { + let profile = makeProfile(inviteSuffix: "?retry=success") + let service = RetryReferralProfileService(profile: profile) + let viewModel = ReferralProfileViewModel( + service: service, + store: InMemoryReferralProfileStore() + ) + + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + XCTAssertEqual( + viewModel.state, + .failed(messageKey: "account.referral.error.network") + ) + + await viewModel.refresh() + + XCTAssertEqual(viewModel.state, .loaded(profile)) + XCTAssertFalse(viewModel.isRefreshing) + } + + @MainActor + func testHTTPFailuresMapToFiniteRetryStates() async { + let cases: [(AccountAPIError, String)] = [ + ( + .server(statusCode: 401, code: "http_error", message: "unauthorized"), + "account.referral.error.session" + ), + ( + .server(statusCode: 404, code: "http_error", message: "missing"), + "account.referral.error.notFound" + ), + ( + .conflict("already bound"), + "account.referral.error.conflict" + ), + ( + .server(statusCode: 503, code: "http_error", message: "unavailable"), + "account.referral.error.unavailable" + ) + ] + + for (error, expectedKey) in cases { + let viewModel = makeViewModel( + service: ReferralProfileServiceSpy(error: error) + ) + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + XCTAssertEqual(viewModel.state, .failed(messageKey: expectedKey)) + XCTAssertFalse(viewModel.isRefreshing) + } + } + + @MainActor + func testCancellationCannotLeaveViewModelLoading() async { + let service = ReferralProfileServiceSpy( + profiles: [makeProfile()], + delaysNanoseconds: [2_000_000_000] + ) + let viewModel = makeViewModel(service: service) + + viewModel.startSession(accountID: accountA) + viewModel.endSession() + await Task.yield() + + XCTAssertEqual(viewModel.state, .idle) + XCTAssertFalse(viewModel.isRefreshing) + XCTAssertNil(viewModel.refreshErrorKey) + } + + @MainActor + func testRepeatedSessionStartSharesOneAutomaticRequest() async { + let service = ReferralProfileServiceSpy( + profiles: [makeProfile()], + delaysNanoseconds: [20_000_000] + ) + let viewModel = makeViewModel(service: service) + + viewModel.startSession(accountID: accountA) + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + + let loadCount = await service.loadCount() + XCTAssertEqual(loadCount, 1) + } + + @MainActor + func testAccountSwitchRejectsStalePreviousAccountResponse() async { + let profileA = makeProfile(inviteSuffix: "?account=A") + let profileB = makeProfile(inviteSuffix: "?account=B") + let service = ReferralProfileServiceSpy( + profiles: [profileA, profileB], + delaysNanoseconds: [150_000_000, 0], + ignoresCancellationAt: [0] + ) + let store = InMemoryReferralProfileStore() + let viewModel = ReferralProfileViewModel(service: service, store: store) + + viewModel.startSession(accountID: accountA) + await waitUntil { await service.loadCount() == 1 } + viewModel.startSession(accountID: accountB) + await viewModel.refresh() + try? await Task.sleep(nanoseconds: 200_000_000) + + XCTAssertEqual(viewModel.state, .loaded(profileB)) + XCTAssertNil(store.profile(for: accountA)) + XCTAssertEqual(store.profile(for: accountB), profileB) + } + + @MainActor + func testCachedProfileDisplaysBeforeBackgroundRefresh() async { + let cached = makeProfile(inviteSuffix: "?version=cached") + let refreshed = makeProfile(inviteSuffix: "?version=server") + let store = InMemoryReferralProfileStore(profiles: [accountA: cached]) + let service = ReferralProfileServiceSpy( + profiles: [refreshed], + delaysNanoseconds: [50_000_000] + ) + let viewModel = ReferralProfileViewModel(service: service, store: store) + + viewModel.startSession(accountID: accountA) + + XCTAssertEqual(viewModel.state, .loaded(cached)) + XCTAssertTrue(viewModel.isRefreshing) + await viewModel.refresh() + XCTAssertEqual(viewModel.state, .loaded(refreshed)) + XCTAssertEqual(store.profile(for: accountA), refreshed) + } + + @MainActor + func testCachedProfileRemainsVisibleWhenBackgroundRefreshFails() async { + let cached = makeProfile(inviteSuffix: "?version=cached") + let store = InMemoryReferralProfileStore(profiles: [accountA: cached]) + let service = ReferralProfileServiceSpy(error: .transport) + let viewModel = ReferralProfileViewModel(service: service, store: store) + + viewModel.startSession(accountID: accountA) + await viewModel.refresh() + + XCTAssertEqual(viewModel.state, .loaded(cached)) + XCTAssertEqual(viewModel.refreshErrorKey, "account.referral.error.network") + XCTAssertFalse(viewModel.isRefreshing) + } + + @MainActor + func testProfileCacheIsIsolatedByAccountAndSurvivesSessionEnd() async { + let profileA = makeProfile(inviteSuffix: "?account=A") + let profileB = makeProfile(inviteSuffix: "?account=B") + let store = InMemoryReferralProfileStore( + profiles: [accountA: profileA, accountB: profileB] + ) + let viewModel = ReferralProfileViewModel( + service: ReferralProfileServiceSpy(profiles: [profileA]), + store: store + ) + + viewModel.startSession(accountID: accountA) + viewModel.endSession() + + XCTAssertEqual(store.profile(for: accountA), profileA) + XCTAssertEqual(store.profile(for: accountB), profileB) + } + + @MainActor + func testSignInStartsReferralLoadWithoutWaitingForIt() async { + let account = OSGKeyboard.AccountSession( + accountID: accountA, + createdAtEpochSeconds: 1_700_000_000 + ) + let accountService = SignInAccountService(account: account) + let referralService = ReferralProfileServiceSpy( + profiles: [makeProfile()], + delaysNanoseconds: [2_000_000_000] + ) + let coordinator = AccountSessionCoordinator( + dependencies: AccountDependencies( + sessionService: accountService, + centerService: accountService, + referralService: referralService + ), + pendingReferralStore: InMemoryPendingReferralStore(), + referralProfileStore: InMemoryReferralProfileStore() + ) + let clock = ContinuousClock() + let start = clock.now + + await coordinator.signIn( + with: AppleAuthorizationPayload( + identityToken: "identity", + authorizationCode: "authorization", + nonce: "nonce" + ) + ) + + XCTAssertLessThan(start.duration(to: clock.now), .seconds(1)) + XCTAssertEqual( + coordinator.sessionPhase, + AccountSessionCoordinator.SessionPhase.signedIn(account) + ) + XCTAssertEqual( + coordinator.referralProfile.state, + ReferralProfileViewModel.State.loading + ) + coordinator.referralProfile.endSession() + } + + @MainActor + func testShareSourcePreservesExactServerInviteURL() { + let profile = makeProfile(inviteSuffix: "?source=server%20owned#invite") + + XCTAssertEqual( + profile.code.inviteURL.absoluteString, + "https://osglab.com/i/Abcdefghij_1234567890-?source=server%20owned#invite" + ) + } + + @MainActor + private func makeViewModel( + service: ReferralProfileServiceSpy + ) -> ReferralProfileViewModel { + ReferralProfileViewModel( + service: service, + store: InMemoryReferralProfileStore() + ) + } + + @MainActor + private func waitUntil( + _ condition: @escaping () async -> Bool + ) async { + for _ in 0..<100 { + if await condition() { + return + } + try? await Task.sleep(nanoseconds: 1_000_000) + } + XCTFail("Condition was not met before timeout.") + } + + private func makeProfile(inviteSuffix: String = "") -> ReferralProfile { + ReferralProfile( + code: ReferralCode( + code: "Abcdefghij_1234567890-", + inviteURL: URL( + string: "https://osglab.com/i/Abcdefghij_1234567890-\(inviteSuffix)" + )!, + campaignID: nil, + createdAt: Date(timeIntervalSince1970: 1_700_000_000) + ), + binding: nil + ) + } + + private func referralProfileData(inviteSuffix: String = "") -> Data { + Data( + """ + { + "code": { + "code": "Abcdefghij_1234567890-", + "inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-\(inviteSuffix)", + "campaignId": null, + "createdAt": "2026-08-20T12:34:56Z" + }, + "binding": null + } + """.utf8 + ) + } +} + +private actor ReferralProfileServiceSpy: ReferralProfileServicing { + private let profiles: [ReferralProfile] + private let error: AccountAPIError? + private let delaysNanoseconds: [UInt64] + private let ignoresCancellationAt: Set + private var count = 0 + + init( + profiles: [ReferralProfile] = [], + error: AccountAPIError? = nil, + delaysNanoseconds: [UInt64] = [], + ignoresCancellationAt: Set = [] + ) { + self.profiles = profiles + self.error = error + self.delaysNanoseconds = delaysNanoseconds + self.ignoresCancellationAt = ignoresCancellationAt + } + + func loadReferralProfile() async throws -> ReferralProfile { + let index = count + count += 1 + let delay = index < delaysNanoseconds.count ? delaysNanoseconds[index] : 0 + if delay > 0 { + if ignoresCancellationAt.contains(index) { + try? await Task.sleep(nanoseconds: delay) + } else { + try await Task.sleep(nanoseconds: delay) + } + } + if let error { + throw error + } + guard !profiles.isEmpty else { + throw AccountIntegrationError.unavailable + } + return profiles[min(index, profiles.count - 1)] + } + + func loadCount() -> Int { + count + } +} + +private actor RetryReferralProfileService: ReferralProfileServicing { + private let profile: ReferralProfile + private var callCount = 0 + + init(profile: ReferralProfile) { + self.profile = profile + } + + func loadReferralProfile() async throws -> ReferralProfile { + callCount += 1 + if callCount == 1 { + throw AccountAPIError.transport + } + return profile + } +} + +@MainActor +private final class InMemoryReferralProfileStore: ReferralProfileStoring { + private var profiles: [UUID: ReferralProfile] + + init(profiles: [UUID: ReferralProfile] = [:]) { + self.profiles = profiles + } + + func profile(for accountID: UUID) -> ReferralProfile? { + profiles[accountID] + } + + func save(_ profile: ReferralProfile, for accountID: UUID) { + profiles[accountID] = profile + } + + func removeProfile(for accountID: UUID) { + profiles.removeValue(forKey: accountID) + } +} + +@MainActor +private final class InMemoryPendingReferralStore: PendingReferralCodeStoring { + private(set) var code: String? + + func save(_ code: String) { + self.code = code + } + + func clear() { + code = nil + } +} + +private actor SignInAccountService: AccountSessionServicing, AccountCenterServicing { + let account: OSGKeyboard.AccountSession + + init(account: OSGKeyboard.AccountSession) { + self.account = account + } + + func restoreSession() async throws -> OSGKeyboard.AccountSession? { + nil + } + + func signIn( + with payload: AppleAuthorizationPayload + ) async throws -> OSGKeyboard.AccountSession { + account + } + + func signOut() async throws {} + + func deleteAccount(with payload: AppleAuthorizationPayload) async throws {} + + func loadAccountCenter() async throws -> AccountCenterSnapshot { + AccountCenterSnapshot( + account: account, + credits: AccountCreditSummary(balance: 0, usedCredits: 0), + referrals: [] + ) + } + + func redeemReferral(code: String) async throws {} +} + +private extension Array { + var single: Element? { + count == 1 ? first : nil + } +} diff --git a/docs/APPSTORE_METADATA.md b/docs/APPSTORE_METADATA.md index 4452850..23f4129 100644 --- a/docs/APPSTORE_METADATA.md +++ b/docs/APPSTORE_METADATA.md @@ -1,4 +1,4 @@ -# App Store Connect — OSGKeyboard 2.0.0 (build 83) +# App Store Connect — OSGKeyboard 2.0.0 (build 86) > Current metadata baseline for the iOS/iPadOS App Store build. Version and build > numbers come from `project.yml`. The repository also contains a separate @@ -11,7 +11,7 @@ | App name | `OSGKeyboard` | ≤ 30 characters | | Subtitle | `Voice input, everywhere` | ≤ 30 characters | | Bundle ID | `com.osgkeyboard.ios` | iOS host target | -| Version / build | `2.0.0` / `83` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` | +| Version / build | `2.0.0` / `86` | `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` | | Minimum system | iOS/iPadOS 26 | iPhone and iPad | | Primary locale | `en-US` | Simplified Chinese is also bundled | | Primary category | Utilities | | @@ -283,7 +283,7 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. ## Submission checklist -- [ ] Confirm `project.yml` still reads version 2.0.0 / build 83 +- [ ] Confirm `project.yml` still reads version 2.0.0 / build 86 - [ ] Open the existing Xcode project (do not regenerate unless needed) - [ ] Run the release build and test suites on macOS with Xcode 26 - [ ] Replace screenshots with captures from the submitted build @@ -294,4 +294,4 @@ standard HTTPS. Re-evaluate this answer if non-exempt cryptography is added. and mapped to the server credit catalog - [ ] Confirm `ByRockyACoffee` remains an optional consumable tip and unlocks no feature -- [ ] Upload, select build 83, add review notes, and submit +- [ ] Upload, select build 86, add review notes, and submit diff --git a/project.yml b/project.yml index c451549..a6d27b7 100644 --- a/project.yml +++ b/project.yml @@ -49,7 +49,7 @@ settings: STRING_CATALOG_GENERATE_SYMBOLS: YES CLANG_CXX_LANGUAGE_STANDARD: c++17 MARKETING_VERSION: "2.0.0" - CURRENT_PROJECT_VERSION: "85" + CURRENT_PROJECT_VERSION: "86" # 签名配置来自 Signing.local.xcconfig(gitignored,不会被覆盖) # 项目级签名 xcconfig,适用于所有 target