feat(account): add QR invitation poster and 8-character invitation codes

- Invite opens an appearance-matched poster with the app icon, inviter name,
  QR code, and short code; tap for a full-brightness QR view; save (add-only
  Photos access), share, or copy the link. Mac shows the poster in a popover
  and can save it as PNG.
- Accounts get look-alike-free 8-character codes. "I have a code" accepts a
  code, link, or pasted message during the server-defined binding window.
- Permanently rejected redemptions show a specific message and drop the
  stored code instead of retrying on every sign-in.
This commit is contained in:
2026-10-20 20:06:36 +02:00
parent eaacc35cf7
commit 03bfbbe08b
26 changed files with 2269 additions and 86 deletions
+6
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- **QR invitation poster**: the Invite button now opens a minimal poster that automatically matches light or dark appearance, uses the current App Icon, names the inviter (falling back to “OSG User”), and shows the QR code plus the short invitation code in Chinese or English. Tap the poster to show the code full screen at full brightness for face-to-face scanning; your brightness returns when you close it. You can also save the poster to Photos (add-only access), share the image, or copy the link. On Mac, the account page shows the same poster in a popover and can save it as a PNG. / **二维码邀请海报**:「邀请」按钮会打开自动适配黑白外观的极简海报,使用当前 App Icon,显示邀请人用户名(未设置时为「OSG 用户」),并以中英文展示二维码与短邀请码;点海报可全屏显示二维码并自动调到最亮,方便当面扫码,关闭后恢复原亮度;也可保存到相册(仅申请添加权限)、分享图片或复制链接。Mac 账户页以弹出面板展示同款海报,并可存为 PNG。
- **8-character invitation codes**: every account now has a short code such as `K7M2-Q9XA`, printed on the poster, the Mac poster popover, and the invitation landing page. The code skips look-alike characters (0/O, 1/I/L), so it can be read aloud. Tap “I have a code” to type it, or paste an invitation link or a whole chat message instead; the clipboard is never read on its own. A friend who installed from the App Store therefore still gets credited. For the first 7 days after registering, and until an inviter is bound, the home card shows “Enter a code, or invite more friends for credits” with the “I have a code” button, and Invite moves to the bottom of that sheet; the window follows the server’s campaign deadline. The landing page copies the link and opens the App Store in one tap, and keeps the link-open paths that older app builds rely on. / **8 位邀请码**:每个账号都有一个形如 `K7M2-Q9XA` 的短邀请码,印在海报、Mac 海报面板与邀请落地页上;不含易混字符(0/O、1/I/L),念出来也不会输错。点「我有邀请码」即可输入,也可直接粘贴邀请链接或整段聊天消息;App 不会主动读取剪贴板。好友从 App Store 安装后仍能计入邀请。注册后 7 天内且尚未绑定邀请人时,首页卡片显示「输入邀请码,或邀请更多好友获取积分」与「我有邀请码」按钮,「邀请」入口移到弹窗底部;期限以服务器活动截止时间为准。落地页一键「复制邀请并下载」,并保留旧版本 App 依赖的链接打开方式。
### Changed ### Changed
- **Dictation progress on the microphone**: while a dictation is being recognized and polished, the microphone capsule fills from left to right in the brand colour instead of showing a spinner. The fill steps forward when recognition finishes, slows as it nears the end, and completes only when the text is inserted; the polish estimate adapts to how long polishing usually takes on this device. / **麦克风上的听写进度**:听写识别与润色期间,麦克风胶囊改为从左到右填充品牌色,取代原来的转圈。识别完成时进度向前跳一段,越接近结束走得越慢,只有文字真正插入时才填满;润色耗时的预估会按这台设备的实际情况自动校准。 - **Dictation progress on the microphone**: while a dictation is being recognized and polished, the microphone capsule fills from left to right in the brand colour instead of showing a spinner. The fill steps forward when recognition finishes, slows as it nears the end, and completes only when the text is inserted; the polish estimate adapts to how long polishing usually takes on this device. / **麦克风上的听写进度**:听写识别与润色期间,麦克风胶囊改为从左到右填充品牌色,取代原来的转圈。识别完成时进度向前跳一段,越接近结束走得越慢,只有文字真正插入时才填满;润色耗时的预估会按这台设备的实际情况自动校准。
- **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。 - **Messaging shift to AI voice keyboard / voice double / agentic skills**: README and the product page now lead with “Say it. It’s typed. Speaks like you. Acts for you.” — the personalization story (learned style, three-tone replies) moves ahead of typing, clipboard skills are framed as agentic actions, and typing becomes the fallback capability. / **宣传定位转向 AI 语音键盘 / 嘴替 / Agentic 技能**:README 与产品页改用「开口即文字。会说你的话,会办你的事。」定位;个人化卖点(专属风格、三语气回复)提前,剪贴板技能表述升级为 Agentic 行动,打字退为兜底能力。
@@ -15,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- **Misleading edit hint after insertion**: stop showing “Hold to edit your last input” for 10 seconds after every insertion. Holding the microphone asks AI; editing the last input uses the pencil button beside it. / **插入后的误导性编辑提示**:每次插入后不再显示 10 秒的「长按编辑上一条」。长按麦克风是向 AI 提问,编辑上一条请使用旁边的铅笔按钮。 - **Misleading edit hint after insertion**: stop showing “Hold to edit your last input” for 10 seconds after every insertion. Holding the microphone asks AI; editing the last input uses the pencil button beside it. / **插入后的误导性编辑提示**:每次插入后不再显示 10 秒的「长按编辑上一条」。长按麦克风是向 AI 提问,编辑上一条请使用旁边的铅笔按钮。
- **Saving invitation posters**: declare add-only Photos access in the built app and preflight the privacy description before calling PhotoKit, preventing Save Image from terminating the app when the generated project configuration is stale. / **保存邀请海报**:在 App 构建配置中声明“仅添加照片”权限,并在调用 PhotoKit 前检查权限说明,避免生成的工程配置过期时点击“保存图片”导致 App 被系统终止。
- **Invitation redemption retrying forever**: an invitation the server permanently rejected (unknown code, self-referral, already bound, or binding window expired) stayed stored and was retried on every sign-in. These rejections now show a specific message and drop the stored code; network failures still retry. / **邀请兑换无限重试**:服务器明确拒绝的邀请(邀请码无效、邀请自己、已绑定或超出绑定期限)会一直保留并在每次登录时重试。现在这些情况会给出具体提示并清除暂存的邀请码;网络失败仍会重试。
- **Faster managed dictation feedback**: begin the managed Volcengine streaming connection while the microphone touch primes audio, reuse it when recording starts, and keep the live transcript automatically scrolled to the newest recognition text. The early connection is reused only while it is fresh (under 10 seconds) and still open; otherwise it is closed and a new one opens, so an unused early connection can no longer make the next dictation fail. / **托管听写反馈提速**:在麦克风触摸预热音频时同步建立托管火山流式连接,录音开始后直接复用,并让实时转写自动滚动到最新识别文字。提前建立的连接仅在新鲜(10 秒内)且仍可用时复用,否则关闭并重新建立,未被使用的提前连接不会再导致下一次听写失败。 - **Faster managed dictation feedback**: begin the managed Volcengine streaming connection while the microphone touch primes audio, reuse it when recording starts, and keep the live transcript automatically scrolled to the newest recognition text. The early connection is reused only while it is fresh (under 10 seconds) and still open; otherwise it is closed and a new one opens, so an unused early connection can no longer make the next dictation fail. / **托管听写反馈提速**:在麦克风触摸预热音频时同步建立托管火山流式连接,录音开始后直接复用,并让实时转写自动滚动到最新识别文字。提前建立的连接仅在新鲜(10 秒内)且仍可用时复用,否则关闭并重新建立,未被使用的提前连接不会再导致下一次听写失败。
- **Recording kept when live recognition fails**: a streaming recognition error while you were still speaking ended the dictation immediately and discarded the audio. Recording now continues, and when you finish, the recorded audio is retried with batch recognition before any error is shown. After a cancelled or failed dictation, that batch retry also stopped working for the rest of the session; it now works again from the next dictation. / **实时识别失败时保留录音**:说话过程中流式识别一旦出错,本次听写会立即结束并丢弃录音。现在会继续录音,说完后先用批量识别重试已录下的音频,仍失败才提示错误。此前一次听写被取消或失败后,本次会话内的批量重试也会失效;现在从下一次听写起恢复正常。 - **Recording kept when live recognition fails**: a streaming recognition error while you were still speaking ended the dictation immediately and discarded the audio. Recording now continues, and when you finish, the recorded audio is retried with batch recognition before any error is shown. After a cancelled or failed dictation, that batch retry also stopped working for the rest of the session; it now works again from the next dictation. / **实时识别失败时保留录音**:说话过程中流式识别一旦出错,本次听写会立即结束并丢弃录音。现在会继续录音,说完后先用批量识别重试已录下的音频,仍失败才提示错误。此前一次听写被取消或失败后,本次会话内的批量重试也会失效;现在从下一次听写起恢复正常。
- **Stalled cloud ASR losing the recording**: when the streaming recognizer went quiet and the result wait expired, the utterance ended as "no speech" even though the audio was captured. A stalled recognizer now counts as a failure, so the batch ASR fallback retries the recorded audio before giving up. / **云端识别卡住时丢失录音**:流式识别无响应、等待结果超时后,即便录音已采集,本次语音也会直接报「未检测到语音」。现在识别卡住按失败处理,会先用批量识别重试已录下的音频再放弃。 - **Stalled cloud ASR losing the recording**: when the streaming recognizer went quiet and the result wait expired, the utterance ended as "no speech" even though the audio was captured. A stalled recognizer now counts as a failure, so the batch ASR fallback retries the recorded audio before giving up. / **云端识别卡住时丢失录音**:流式识别无响应、等待结果超时后,即便录音已采集,本次语音也会直接报「未检测到语音」。现在识别卡住按失败处理,会先用批量识别重试已录下的音频再放弃。
+2
View File
@@ -108,6 +108,8 @@
<string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string> <string>OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string> <string>OSGKeyboard uses the microphone for voice dictation, including active Flow sessions while you type in other apps.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.</string>
<key>NSRemindersFullAccessUsageDescription</key> <key>NSRemindersFullAccessUsageDescription</key>
<string>OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.</string> <string>OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.</string>
<key>NSSpeechRecognitionUsageDescription</key> <key>NSSpeechRecognitionUsageDescription</key>
@@ -0,0 +1,88 @@
// ScreenBrightnessBoost.swift
// OSGKeyboard · Main App
//
// Reference-counted full-brightness hold for content another device scans,
// such as the invitation QR code. iOS keeps an app-set brightness after the
// app leaves the foreground, so the hold restores the user's level while the
// app is in the background and reapplies it on return.
import SwiftUI
import UIKit
@MainActor
protocol ScreenBrightnessControlling: AnyObject {
var brightness: CGFloat { get set }
}
extension UIScreen: ScreenBrightnessControlling {}
@MainActor
final class ScreenBrightnessBoost {
static let shared = ScreenBrightnessBoost(screenProvider: foregroundScreen)
private let screenProvider: @MainActor () -> ScreenBrightnessControlling?
private var holdCount = 0
private var isInForeground = true
/// Set only while boosted, so the user's level is captured exactly once.
private var boost: (screen: ScreenBrightnessControlling, originalBrightness: CGFloat)?
init(screenProvider: @escaping @MainActor () -> ScreenBrightnessControlling?) {
self.screenProvider = screenProvider
}
func acquire() {
holdCount += 1
update()
}
func release() {
guard holdCount > 0 else { return }
holdCount -= 1
update()
}
func setInForeground(_ inForeground: Bool) {
isInForeground = inForeground
update()
}
private func update() {
let wantsBoost = holdCount > 0 && isInForeground
if wantsBoost, boost == nil, let screen = screenProvider() {
boost = (screen, screen.brightness)
screen.brightness = 1
} else if !wantsBoost, let boost {
boost.screen.brightness = boost.originalBrightness
self.boost = nil
}
}
private static func foregroundScreen() -> ScreenBrightnessControlling? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first { $0.activationState == .foregroundActive }?
.screen
}
}
extension View {
/// Holds full screen brightness while this view is on screen.
func boostsScreenBrightness() -> some View {
modifier(ScreenBrightnessBoostModifier())
}
}
private struct ScreenBrightnessBoostModifier: ViewModifier {
@Environment(\.scenePhase) private var scenePhase
func body(content: Content) -> some View {
content
.onAppear { ScreenBrightnessBoost.shared.acquire() }
.onDisappear { ScreenBrightnessBoost.shared.release() }
// `.inactive` is skipped on purpose: Control Center makes the scene
// inactive, and restoring there would fight a manual adjustment.
.onChange(of: scenePhase) { _, phase in
ScreenBrightnessBoost.shared.setInForeground(phase != .background)
}
}
}
@@ -80,19 +80,34 @@ struct ReferralCode: Codable, Equatable, Sendable {
let inviteURL: URL let inviteURL: URL
let campaignID: String? let campaignID: String?
let createdAt: Date let createdAt: Date
/// 8-character alias friends can type; nil until the server assigns one.
let shortCode: String?
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case code case code
case inviteURL = "inviteUrl" case inviteURL = "inviteUrl"
case campaignID = "campaignId" case campaignID = "campaignId"
case createdAt case createdAt
case shortCode
} }
init(code: String, inviteURL: URL, campaignID: String?, createdAt: Date) { init(
code: String,
inviteURL: URL,
campaignID: String?,
createdAt: Date,
shortCode: String? = nil
) {
self.code = code self.code = code
self.inviteURL = inviteURL self.inviteURL = inviteURL
self.campaignID = campaignID self.campaignID = campaignID
self.createdAt = createdAt self.createdAt = createdAt
self.shortCode = shortCode.flatMap(ReferralShortCode.normalize)
}
/// `XXXX-XXXX` form for posters and share sheets.
var displayShortCode: String? {
shortCode.map(ReferralShortCode.display)
} }
init(from decoder: Decoder) throws { init(from decoder: Decoder) throws {
@@ -115,6 +130,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
self.inviteURL = inviteURL self.inviteURL = inviteURL
campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID) campaignID = try container.decodeIfPresent(String.self, forKey: .campaignID)
self.createdAt = createdAt self.createdAt = createdAt
// A malformed alias only hides the short code; the link still works.
shortCode = (try? container.decodeIfPresent(String.self, forKey: .shortCode))
.flatMap { $0 }
.flatMap(ReferralShortCode.normalize)
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -123,9 +142,10 @@ struct ReferralCode: Codable, Equatable, Sendable {
try container.encode(inviteURL, forKey: .inviteURL) try container.encode(inviteURL, forKey: .inviteURL)
try container.encodeIfPresent(campaignID, forKey: .campaignID) try container.encodeIfPresent(campaignID, forKey: .campaignID)
try container.encode(Self.string(from: createdAt), forKey: .createdAt) try container.encode(Self.string(from: createdAt), forKey: .createdAt)
try container.encodeIfPresent(shortCode, forKey: .shortCode)
} }
private static func date(from value: String) -> Date? { static func date(from value: String) -> Date? {
let fractional = ISO8601DateFormatter() let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let standard = ISO8601DateFormatter() let standard = ISO8601DateFormatter()
@@ -133,7 +153,7 @@ struct ReferralCode: Codable, Equatable, Sendable {
return fractional.date(from: value) ?? standard.date(from: value) return fractional.date(from: value) ?? standard.date(from: value)
} }
private static func string(from date: Date) -> String { static func string(from date: Date) -> String {
let formatter = ISO8601DateFormatter() let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter.string(from: date) return formatter.string(from: date)
@@ -214,6 +234,40 @@ enum ReferralJSONValue: Codable, Equatable, Sendable {
struct ReferralProfile: Codable, Equatable, Sendable { struct ReferralProfile: Codable, Equatable, Sendable {
let code: ReferralCode let code: ReferralCode
let binding: ReferralBinding? let binding: ReferralBinding?
/// Last moment this account may still bind an inviter. The server omits it
/// once bound or when no campaign is active; older servers never send it.
let bindingDeadline: Date?
private enum CodingKeys: String, CodingKey {
case code
case binding
case bindingDeadline
}
init(code: ReferralCode, binding: ReferralBinding?, bindingDeadline: Date? = nil) {
self.code = code
self.binding = binding
self.bindingDeadline = bindingDeadline
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decode(ReferralCode.self, forKey: .code)
binding = try container.decodeIfPresent(ReferralBinding.self, forKey: .binding)
bindingDeadline = (try? container.decodeIfPresent(String.self, forKey: .bindingDeadline))
.flatMap { $0 }
.flatMap(ReferralCode.date(from:))
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(code, forKey: .code)
try container.encodeIfPresent(binding, forKey: .binding)
try container.encodeIfPresent(
bindingDeadline.map(ReferralCode.string(from:)),
forKey: .bindingDeadline
)
}
} }
struct AccountCenterSnapshot: Equatable, Sendable { struct AccountCenterSnapshot: Equatable, Sendable {
@@ -448,6 +502,38 @@ enum ReferralUniversalLink {
return code return code
} }
/// Extracts a code from typed or pasted text: a short code, a bare long
/// code, an invitation link, or a chat message that contains one of them.
/// Links are matched with the same host and path rules as Universal Links,
/// so arbitrary URLs never qualify. Short codes are returned normalized
/// (uppercase, no hyphen); the server resolves them to the long code.
static func code(fromUserInput text: String) -> String? {
let separators = CharacterSet.whitespacesAndNewlines
.union(CharacterSet(charactersIn: ",。;:、!?「」『』“”‘’<>()()【】《》"))
let tokens = text
.components(separatedBy: separators)
.filter { !$0.isEmpty }
for token in tokens {
if isValid(code: token) {
return token
}
// Chat apps often glue the link to the preceding sentence.
let link = token.range(of: "https://", options: .caseInsensitive)
.map { String(token[$0.lowerBound...]) } ?? token
if let url = URL(string: link), let code = code(from: url) {
return code
}
}
// Short codes go last: an 8-letter word in a pasted message must not
// shadow the link that message carries.
return ReferralShortCode.firstMatch(in: text)
}
/// Anything the bind endpoint accepts: a long code or a normalized short code.
static func isRedeemable(code: String) -> Bool {
isValid(code: code) || ReferralShortCode.isCanonical(code)
}
static func isValid(code: String) -> Bool { static func isValid(code: String) -> Bool {
guard code.utf8.count == codeLength else { return false } guard code.utf8.count == codeLength else { return false }
return code.utf8.allSatisfy { byte in return code.utf8.allSatisfy { byte in
@@ -461,6 +547,73 @@ enum ReferralUniversalLink {
} }
/// 8-character typeable alias of a referral code. Mirrors the server's
/// `ReferralShortCode`: the alphabet drops 0/O and 1/I/L so a code read aloud
/// or copied from a poster cannot be mistyped into another valid code.
enum ReferralShortCode {
static let length = 8
static let alphabet: Set<Character> = Set("23456789ABCDEFGHJKMNPQRSTUVWXYZ")
static func isCanonical(_ value: String) -> Bool {
value.count == length && value.allSatisfy(alphabet.contains)
}
/// Accepts `K7M2Q9XA` or `k7m2-q9xa`; returns the stored uppercase form.
static func normalize(_ raw: String) -> String? {
var value = raw.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
if value.count == length + 1, value[value.index(value.startIndex, offsetBy: 4)] == "-" {
value.remove(at: value.index(value.startIndex, offsetBy: 4))
}
return isCanonical(value) ? value : nil
}
static func display(_ value: String) -> String {
guard isCanonical(value) else { return value }
return "\(value.prefix(4))-\(value.suffix(4))"
}
/// Finds a short code inside free text such as "邀请码 K7M2-Q9XA" or a
/// Chinese sentence glued to the code, which whitespace splitting misses.
static func firstMatch(in text: String) -> String? {
// Runs of code-like ASCII; anything else (spaces, CJK, punctuation)
// separates them, so a run is exactly one candidate. "_" stays inside
// a run so a fragment of a long code never reads as a short code.
let runs = text.split { character in
!(character.isASCII
&& (character.isLetter || character.isNumber || character == "-" || character == "_"))
}
return runs.lazy.compactMap { normalize(String($0)) }.first
}
}
/// When a signed-in account may still bind an inviter. Prefers the server's
/// `bindingDeadline`, which reflects the active campaign; without it, falls
/// back to the default `REFERRAL_BINDING_DAYS`. The server stays
/// authoritative, so a mismatch still surfaces as a redemption error.
enum ReferralBindingWindow {
static let duration: TimeInterval = 7 * 24 * 60 * 60
static func isOpen(for account: AccountSession, now: Date = Date()) -> Bool {
// Zero means the account record has not loaded yet.
guard account.createdAtEpochSeconds > 0 else { return false }
let registeredAt = Date(timeIntervalSince1970: TimeInterval(account.createdAtEpochSeconds))
return now.timeIntervalSince(registeredAt) < duration
}
/// Open only while unbound and before the deadline the server reported.
static func isOpen(
for account: AccountSession,
profile: ReferralProfile,
now: Date = Date()
) -> Bool {
guard profile.binding == nil else { return false }
if let deadline = profile.bindingDeadline {
return now <= deadline
}
return isOpen(for: account, now: now)
}
}
@MainActor @MainActor
protocol PendingReferralCodeStoring: AnyObject { protocol PendingReferralCodeStoring: AnyObject {
var code: String? { get } var code: String? { get }
@@ -480,7 +633,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
var code: String? { var code: String? {
guard let value = defaults.string(forKey: Self.storageKey), guard let value = defaults.string(forKey: Self.storageKey),
ReferralUniversalLink.isValid(code: value) ReferralUniversalLink.isRedeemable(code: value)
else { else {
return nil return nil
} }
@@ -488,7 +641,7 @@ final class UserDefaultsPendingReferralCodeStore: PendingReferralCodeStoring {
} }
func save(_ code: String) { func save(_ code: String) {
guard ReferralUniversalLink.isValid(code: code) else { return } guard ReferralUniversalLink.isRedeemable(code: code) else { return }
defaults.set(code, forKey: Self.storageKey) defaults.set(code, forKey: Self.storageKey)
} }
@@ -29,7 +29,8 @@ struct AccountCenterUITestHarness: View {
"--account-snapshot-failure" "--account-snapshot-failure"
), ),
startsSignedOut: startsSignIn || staysSignedOut, startsSignedOut: startsSignIn || staysSignedOut,
signInDelay: startsSignIn ? .seconds(5) : .zero signInDelay: startsSignIn ? .seconds(5) : .zero,
isInsideReferralWindow: arguments.contains("--account-referral-window")
) )
self.startsSignIn = startsSignIn self.startsSignIn = startsSignIn
let store = AccountCenterUITestCreditStore(accountID: service.account.accountID) let store = AccountCenterUITestCreditStore(accountID: service.account.accountID)
@@ -122,15 +123,18 @@ private actor AccountCenterUITestService:
private let shouldFailSnapshot: Bool private let shouldFailSnapshot: Bool
private let startsSignedOut: Bool private let startsSignedOut: Bool
private let signInDelay: Duration private let signInDelay: Duration
private let isInsideReferralWindow: Bool
init( init(
shouldFailSnapshot: Bool = false, shouldFailSnapshot: Bool = false,
startsSignedOut: Bool = false, startsSignedOut: Bool = false,
signInDelay: Duration = .zero signInDelay: Duration = .zero,
isInsideReferralWindow: Bool = false
) { ) {
self.shouldFailSnapshot = shouldFailSnapshot self.shouldFailSnapshot = shouldFailSnapshot
self.startsSignedOut = startsSignedOut self.startsSignedOut = startsSignedOut
self.signInDelay = signInDelay self.signInDelay = signInDelay
self.isInsideReferralWindow = isInsideReferralWindow
} }
func restoreSession() async throws -> AccountSession? { func restoreSession() async throws -> AccountSession? {
@@ -180,9 +184,12 @@ private actor AccountCenterUITestService:
code: "UITestInvite_123456789", code: "UITestInvite_123456789",
inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!, inviteURL: URL(string: "https://osglab.com/i/UITestInvite_123456789")!,
campaignID: nil, campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000) createdAt: Date(timeIntervalSince1970: 1_700_000_000),
shortCode: "K7M2Q9XA"
), ),
binding: nil binding: nil,
// The fixed account is years old; a server deadline reopens the window.
bindingDeadline: isInsideReferralWindow ? Date().addingTimeInterval(3 * 86_400) : nil
) )
} }
@@ -111,12 +111,7 @@ struct AccountCenterView: View {
AccountAppleAuthorizationButton(purpose: .signIn) AccountAppleAuthorizationButton(purpose: .signIn)
.disabled(coordinator.operation != nil) .disabled(coordinator.operation != nil)
if coordinator.pendingReferralCode != nil { ReferralCodeEntryRow()
Label(AppL10n.string("account.referral.pendingAfterSignIn"), systemImage: "link")
.font(.footnote)
.foregroundStyle(palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
Text(AppL10n.string("account.signedOut.localUnaffected")) Text(AppL10n.string("account.signedOut.localUnaffected"))
.font(.footnote) .font(.footnote)
@@ -136,6 +131,10 @@ struct AccountCenterView: View {
ScrollView { ScrollView {
CardPageContent { CardPageContent {
accountSummarySection(session: session) accountSummarySection(session: session)
SignedInReferralCodeCard(
profile: coordinator.referralProfile,
session: session
)
accountRewardsContent(session: session) accountRewardsContent(session: session)
securitySection securitySection
} }
@@ -769,20 +768,55 @@ struct AccountCreditProgress: View {
struct AccountInvitationButton: View { struct AccountInvitationButton: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@State private var showsShareDrawer = false @State private var showsShareDrawer = false
let invitationURL: URL? let invitationURL: URL?
var shortCode: String?
var body: some View { var body: some View {
Button { Button {
guard invitationURL != nil else { return } guard invitationURL != nil else { return }
showsShareDrawer = true showsShareDrawer = true
} label: { } label: {
Label(AppL10n.string("account.referral.inviteTitle"), systemImage: "person.badge.plus") AccountReferralActionLabel(
titleKey: "account.referral.inviteTitle",
systemImage: "person.badge.plus"
)
}
.buttonStyle(.plain)
.disabled(invitationURL == nil)
.accessibilityIdentifier("account.referral.share")
.sheet(isPresented: $showsShareDrawer) {
if let invitationURL {
InviteShareSheet(
invitationURL: invitationURL,
shortCode: shortCode,
referralProfile: coordinator.referralProfile
)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
}
}
}
/// Green rounded-rectangle label shared by the Invite and "I have a code"
/// buttons so the two read as one family on the account card.
struct AccountReferralActionLabel: View {
@Environment(\.themePalette) private var palette
let titleKey: String
let systemImage: String
/// Full-width form for a sheet's primary action.
var fillsWidth = false
var body: some View {
Label(AppL10n.string(titleKey), systemImage: systemImage)
.font(TypeStyle.bodyEmph) .font(TypeStyle.bodyEmph)
.foregroundStyle(OSGColor.fixedLightContent) .foregroundStyle(OSGColor.fixedLightContent)
.padding(.horizontal, Spacing.sm) .padding(.horizontal, Spacing.sm)
.frame(minHeight: 38) .frame(maxWidth: fillsWidth ? .infinity : nil, minHeight: fillsWidth ? 44 : 38)
.background( .background(
palette.success, palette.success,
in: RoundedRectangle( in: RoundedRectangle(
@@ -791,40 +825,6 @@ struct AccountInvitationButton: View {
) )
) )
} }
.buttonStyle(.plain)
.disabled(invitationURL == nil)
.accessibilityIdentifier("account.referral.share")
.sheet(isPresented: $showsShareDrawer) {
if let invitationURL {
AccountInvitationActivityView(invitationURL: invitationURL)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
}
}
private struct AccountInvitationActivityView: UIViewControllerRepresentable {
let invitationURL: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: [invitationURL],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, completed, _, _ in
guard completed else { return }
Task { @MainActor in
AnalyticsHostService.shared.client.recordReferralShared()
}
}
return controller
}
func updateUIViewController(
_ uiViewController: UIActivityViewController,
context: Context
) {}
} }
#if DEBUG #if DEBUG
@@ -63,6 +63,9 @@ struct AccountRewardsCard: View {
AccountAppleAuthorizationButton(purpose: .signIn) AccountAppleAuthorizationButton(purpose: .signIn)
.disabled(coordinator.operation != nil) .disabled(coordinator.operation != nil)
Divider().background(palette.divider)
ReferralCodeEntryRow()
} }
.padding(Spacing.md) .padding(Spacing.md)
} }
@@ -90,10 +93,18 @@ struct AccountRewardsCard: View {
} }
Divider().background(palette.divider) Divider().background(palette.divider)
AccountReferralLinkView(viewModel: coordinator.referralProfile) AccountReferralLinkView(
viewModel: coordinator.referralProfile,
session: signedInSession
)
} }
} }
private var signedInSession: AccountSession? {
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
return session
}
private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View { private func creditSummary(_ snapshot: AccountCenterSnapshot) -> some View {
let totalCredits = creditTotal(snapshot) let totalCredits = creditTotal(snapshot)
return Button(action: onOpenAccount) { return Button(action: onOpenAccount) {
@@ -185,6 +196,9 @@ struct AccountRewardsCard: View {
private struct AccountReferralLinkView: View { private struct AccountReferralLinkView: View {
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
@ObservedObject var viewModel: ReferralProfileViewModel @ObservedObject var viewModel: ReferralProfileViewModel
let session: AccountSession?
@State private var isCodeEntryPresented = false
var body: some View { var body: some View {
Group { Group {
@@ -217,12 +231,27 @@ private struct AccountReferralLinkView: View {
private func loadedContent(_ profile: ReferralProfile) -> some View { private func loadedContent(_ profile: ReferralProfile) -> some View {
VStack(alignment: .leading, spacing: Spacing.sm) { VStack(alignment: .leading, spacing: Spacing.sm) {
HStack(spacing: Spacing.md) { HStack(spacing: Spacing.md) {
Text(AppL10n.string("account.referral.equalRewardDescription")) Text(AppL10n.string(
showsCodeEntry(profile)
? "account.referral.codeEntry.windowDescription"
: "account.referral.equalRewardDescription"
))
.font(TypeStyle.body.weight(.bold)) .font(TypeStyle.body.weight(.bold))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs) Spacer(minLength: Spacing.xs)
AccountInvitationButton(invitationURL: profile.code.inviteURL) if showsCodeEntry(profile) {
// Inviting stays reachable from the bottom of the sheet.
ReferralCodeEntryButton(
isPresented: $isCodeEntryPresented,
invitation: profile.code
)
} else {
AccountInvitationButton(
invitationURL: profile.code.inviteURL,
shortCode: profile.code.displayShortCode
)
}
} }
if viewModel.isRefreshing { if viewModel.isRefreshing {
@@ -239,6 +268,15 @@ private struct AccountReferralLinkView: View {
} }
} }
/// New accounts that can still bind an inviter see "I have a code" in
/// place of Invite. The open sheet keeps the button mounted, so a
/// redemption that closes the window cannot tear the sheet down mid-confirmation.
private func showsCodeEntry(_ profile: ReferralProfile) -> Bool {
if isCodeEntryPresented { return true }
guard let session else { return false }
return ReferralBindingWindow.isOpen(for: session, profile: profile)
}
private func retryRow( private func retryRow(
messageKey: String, messageKey: String,
actionKey: String actionKey: String
@@ -10,9 +10,20 @@ import Foundation
#if canImport(OSGKeyboardShared) #if canImport(OSGKeyboardShared)
import OSGKeyboardShared import OSGKeyboardShared
#endif #endif
#if canImport(OSGKeyboardHostSupport)
import OSGKeyboardHostSupport
#endif
@MainActor @MainActor
final class AccountSessionCoordinator: ObservableObject { final class AccountSessionCoordinator: ObservableObject {
/// Result of an invitation the user typed or pasted instead of opening a link.
enum ReferralInputOutcome: Equatable {
case invalidInput
case savedForSignIn
case redeemed
case failed(messageKey: String)
}
enum SessionPhase: Equatable { enum SessionPhase: Equatable {
case restoring case restoring
case signedOut case signedOut
@@ -208,11 +219,8 @@ final class AccountSessionCoordinator: ObservableObject {
func handleIncomingURL(_ url: URL) -> Bool { func handleIncomingURL(_ url: URL) -> Bool {
guard let code = ReferralUniversalLink.code(from: url) else { return false } guard let code = ReferralUniversalLink.code(from: url) else { return false }
pendingReferralStore.save(code) stagePendingReferral(code)
pendingReferralCode = code
shouldPresentAccountCenter = true shouldPresentAccountCenter = true
operationErrorKey = nil
operationErrorDetail = nil
analyticsClient.recordInviteOpened() analyticsClient.recordInviteOpened()
if isSignedIn { if isSignedIn {
@@ -224,6 +232,26 @@ final class AccountSessionCoordinator: ObservableObject {
return true return true
} }
/// Manual path for invitations that never reached the app as a Universal
/// Link — typically a friend who scanned a poster, installed from the App
/// Store, and typed the short code (or pasted the link). The outcome is
/// returned to the caller instead of `operationErrorKey` so the code-entry
/// sheet can explain it inline rather than through the global alert.
func submitReferralInput(_ text: String) async -> ReferralInputOutcome {
guard let code = ReferralUniversalLink.code(fromUserInput: text) else {
return .invalidInput
}
stagePendingReferral(code)
guard isSignedIn else { return .savedForSignIn }
if let messageKey = await redeemPendingReferral() {
return .failed(messageKey: messageKey)
}
await referralProfile.refresh()
await refreshAccountData(force: true)
return .redeemed
}
func consumeAccountCenterPresentation() -> Bool { func consumeAccountCenterPresentation() -> Bool {
guard shouldPresentAccountCenter else { return false } guard shouldPresentAccountCenter else { return false }
shouldPresentAccountCenter = false shouldPresentAccountCenter = false
@@ -544,22 +572,84 @@ final class AccountSessionCoordinator: ObservableObject {
sessionRevision &+= 1 sessionRevision &+= 1
} }
private func stagePendingReferral(_ code: String) {
pendingReferralStore.save(code)
pendingReferralCode = code
operationErrorKey = nil
operationErrorDetail = nil
}
private func redeemPendingReferralIfNeeded() async { private func redeemPendingReferralIfNeeded() async {
guard isSignedIn, let code = pendingReferralStore.code else { return } if let messageKey = await redeemPendingReferral() {
operationErrorKey = messageKey
}
}
/// Returns nil on success (or when nothing is pending), otherwise the
/// message key. Rejections the server will repeat forever drop the stored
/// code; transient failures keep it for the next sign-in or launch.
private func redeemPendingReferral() async -> String? {
guard isSignedIn, let code = pendingReferralStore.code else { return nil }
operation = .redeemingReferral operation = .redeemingReferral
defer { operation = nil }
do { do {
try await centerService.redeemReferral(code: code) try await centerService.redeemReferral(code: code)
pendingReferralStore.clear() pendingReferralStore.clear()
pendingReferralCode = nil pendingReferralCode = nil
return nil
} catch { } catch {
let failure = Self.referralRedemptionFailure(for: error)
if failure.isPermanent {
pendingReferralStore.clear()
pendingReferralCode = nil
} else {
pendingReferralCode = code pendingReferralCode = code
operationErrorKey = errorMessageKey(
for: error,
fallback: "account.error.redeemReferral"
)
} }
operation = nil return failure.messageKey
}
}
private static func referralRedemptionFailure(
for error: Error
) -> (messageKey: String, isPermanent: Bool) {
if error as? AccountIntegrationError == .unavailable {
return ("account.error.unavailable", false)
}
guard let apiError = error as? AccountAPIError else {
return ("account.error.redeemReferral", false)
}
// Referral routes answer with a bare `{"error": message}` body, so most
// rejections arrive as `.server` and are told apart by status code.
switch apiError {
case .invalidRequest:
return ("account.referral.redeem.error.invalid", true)
case .conflict(let message):
return (conflictMessageKey(for: message), true)
case let .server(statusCode, _, message):
switch statusCode {
case 400, 404:
return ("account.referral.redeem.error.invalid", true)
case 409:
return (conflictMessageKey(for: message), true)
case 422:
return ("account.referral.redeem.error.expired", true)
default:
return ("account.error.redeemReferral", false)
}
default:
return ("account.error.redeemReferral", false)
}
}
private static func conflictMessageKey(for message: String) -> String {
if message.localizedCaseInsensitiveContains("self-referral") {
return "account.referral.redeem.error.self"
}
if message.localizedCaseInsensitiveContains("already bound") {
return "account.referral.redeem.error.alreadyBound"
}
return "account.referral.redeem.error.ineligible"
} }
private func errorMessageKey(for error: Error, fallback: String) -> String { private func errorMessageKey(for error: Error, fallback: String) -> String {
@@ -0,0 +1,329 @@
// InvitePoster.swift
// OSGKeyboard · Main App + Mac
//
// Shareable invitation artwork: the app icon, inviter name, a QR code for the
// account's invitation link, and the short code for friends who type it
// instead of scanning. The QR code is generated on device with Core Image,
// so the link is never sent anywhere to be rendered. The Mac target compiles
// this file at the source level (see project.yml), so it must stay free of
// UIKit and AppKit.
import CoreImage
import CoreImage.CIFilterBuiltins
import SwiftUI
#if canImport(OSGKeyboardShared)
import OSGKeyboardShared
#endif
enum InviteQRCode {
/// Pixels per QR module. Integer scaling keeps module edges sharp.
fileprivate static let moduleScale: CGFloat = 12
static func makeImage(for url: URL) -> CGImage? {
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(url.absoluteString.utf8)
// "M" (15%) survives chat-app recompression. Nothing is drawn over the
// code, so a higher level would only shrink the modules.
filter.correctionLevel = "M"
guard let output = filter.outputImage else { return nil }
let scaled = output.transformed(
by: CGAffineTransform(scaleX: moduleScale, y: moduleScale)
)
// QRGenerator produces a one-channel image that SwiftUI can treat as a
// template and recolor white in dark mode. Materialize explicit black
// and white pixels so both poster appearances remain scannable.
let falseColor = CIFilter.falseColor()
falseColor.inputImage = scaled
falseColor.color0 = CIColor.black
falseColor.color1 = CIColor.white
guard let colored = falseColor.outputImage else { return nil }
return CIContext().createCGImage(colored, from: colored.extent)
}
}
/// Fixed 3:4 layout so the exported image is the same on every device and
/// is never cropped in chat previews or Moments.
struct InvitePosterView: View {
static let size = CGSize(width: 360, height: 480)
let qrImage: CGImage
let language: AppUILanguage?
let inviterName: String?
let colorScheme: ColorScheme
/// `XXXX-XXXX`; lets a friend who cannot scan type the code instead.
var shortCode: String?
var body: some View {
VStack(spacing: 0) {
Spacer(minLength: 28)
// Use the active Icon Composer artwork, not the horizontal logo.
Image("AppIcon_Assets/OSGKeyboard Icon2")
.resizable()
.scaledToFit()
.frame(width: 64, height: 64)
.accessibilityHidden(true)
Text(
SharedL10n.format(
"invite.poster.inviter",
language: language,
resolvedInviterName
)
)
.font(.system(size: 18, weight: .medium, design: .default))
.foregroundStyle(primaryContent)
.multilineTextAlignment(.center)
.lineLimit(2)
.lineSpacing(3)
.minimumScaleFactor(0.75)
.padding(.top, 24)
// Dark modules on a light card scan most reliably, including in
// WeChat's long-press recognizer.
InviteQRCodeView(image: qrImage)
.frame(width: 156, height: 156)
.padding(Spacing.sm)
.background(
OSGColor.fixedLightContent,
in: Rectangle()
)
.padding(.top, 28)
Text(SharedL10n.string("invite.poster.scan", language: language))
.font(.system(size: 12, weight: .regular, design: .default))
.foregroundStyle(secondaryContent)
.multilineTextAlignment(.center)
.padding(.top, 28)
if let shortCode {
Text(SharedL10n.format("invite.poster.code", language: language, shortCode))
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundStyle(primaryContent)
.padding(.top, 10)
}
Spacer(minLength: 32)
}
.padding(.horizontal, Spacing.xxl)
.frame(width: Self.size.width, height: Self.size.height)
.background(InvitePosterBackground(colorScheme: colorScheme))
}
private var resolvedInviterName: String {
let trimmed = inviterName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty
? SharedL10n.string("invite.poster.defaultInviter", language: language)
: trimmed
}
private var primaryContent: Color {
colorScheme == .dark ? Color(white: 0.96) : Color(white: 0.08)
}
private var secondaryContent: Color {
primaryContent.opacity(0.62)
}
}
/// Draws each QR module as a vector rectangle. `ImageRenderer` can otherwise
/// reinterpret a CGImage as a template on complex dark backgrounds and erase
/// its black modules. Vector modules stay black in every appearance.
private struct InviteQRCodeView: View {
private let sideCount: Int
private let darkModules: [Bool]
init(image: CGImage) {
let matrix = Self.moduleMatrix(from: image)
sideCount = matrix.sideCount
darkModules = matrix.darkModules
}
var body: some View {
Canvas { context, size in
guard sideCount > 0 else { return }
let moduleWidth = size.width / CGFloat(sideCount)
let moduleHeight = size.height / CGFloat(sideCount)
var modules = Path()
for row in 0..<sideCount {
for column in 0..<sideCount where darkModules[row * sideCount + column] {
modules.addRect(
CGRect(
x: CGFloat(column) * moduleWidth,
y: CGFloat(row) * moduleHeight,
width: moduleWidth,
height: moduleHeight
)
)
}
}
context.fill(modules, with: .color(OSGColor.fixedDarkContent))
}
.background(OSGColor.fixedLightContent)
}
private static func moduleMatrix(
from image: CGImage
) -> (sideCount: Int, darkModules: [Bool]) {
let sideCount = max(image.width / Int(InviteQRCode.moduleScale), 1)
var pixels = [UInt8](repeating: 0, count: sideCount * sideCount * 4)
guard let context = CGContext(
data: &pixels,
width: sideCount,
height: sideCount,
bitsPerComponent: 8,
bytesPerRow: sideCount * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return (0, [])
}
context.interpolationQuality = .none
context.draw(
image,
in: CGRect(x: 0, y: 0, width: sideCount, height: sideCount)
)
let darkModules = (0..<(sideCount * sideCount)).map { index in
let offset = index * 4
let luminance = Int(pixels[offset])
+ Int(pixels[offset + 1])
+ Int(pixels[offset + 2])
return luminance < 3 * 128
}
return (sideCount, darkModules)
}
}
/// A restrained paper surface: warm Nordic stock in light mode and graphite
/// stock in dark mode. The daylight bands and fibers stay below text contrast,
/// so the background adds depth without becoming decoration.
private struct InvitePosterBackground: View {
let colorScheme: ColorScheme
var body: some View {
GeometryReader { proxy in
ZStack {
base
LinearGradient(
colors: daylightColors,
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Ellipse()
.fill(topTrailingShadow)
.frame(width: 310, height: 250)
.offset(x: 178, y: -186)
.blur(radius: 62)
Ellipse()
.fill(bottomLeadingShadow)
.frame(width: 300, height: 260)
.offset(x: -182, y: 224)
.blur(radius: 68)
Rectangle()
.fill(diagonalShadow)
.frame(width: proxy.size.width * 1.55, height: 82)
.rotationEffect(.degrees(-18))
.offset(x: 72, y: -118)
.blur(radius: 34)
paperFibers(in: proxy.size)
}
}
}
private var base: Color {
colorScheme == .dark
? Color(red: 0.055, green: 0.054, blue: 0.050)
: Color(red: 0.965, green: 0.953, blue: 0.922)
}
private var daylightColors: [Color] {
if colorScheme == .dark {
return [
.white.opacity(0.085),
.clear,
.black.opacity(0.30)
]
}
return [
.white.opacity(0.62),
.clear,
Color(red: 0.52, green: 0.48, blue: 0.41).opacity(0.18)
]
}
private var topTrailingShadow: Color {
colorScheme == .dark
? .black.opacity(0.38)
: Color(red: 0.43, green: 0.40, blue: 0.35).opacity(0.24)
}
private var bottomLeadingShadow: Color {
colorScheme == .dark
? .black.opacity(0.28)
: Color(red: 0.48, green: 0.44, blue: 0.37).opacity(0.15)
}
private var diagonalShadow: Color {
colorScheme == .dark
? .white.opacity(0.055)
: Color.black.opacity(0.055)
}
private func paperFibers(in size: CGSize) -> some View {
ZStack {
ForEach(0..<48, id: \.self) { index in
Rectangle()
.fill(fiberColor)
.frame(width: 2 + 7 * fraction(index * 19), height: 0.35)
.position(
x: size.width * fraction(index * 37),
y: size.height * fraction(index * 61)
)
}
}
.allowsHitTesting(false)
}
private var fiberColor: Color {
colorScheme == .dark
? Color.white.opacity(0.025)
: Color.black.opacity(0.025)
}
private func fraction(_ value: Int) -> CGFloat {
CGFloat(value % 101) / 100
}
}
enum InvitePosterRenderer {
/// 3× the point layout: a 1080×1440 px image.
static let exportScale: CGFloat = 3
@MainActor
static func render(
inviteURL: URL,
shortCode: String? = nil,
inviterName: String? = nil,
language: AppUILanguage?,
colorScheme: ColorScheme
) -> CGImage? {
guard let qrImage = InviteQRCode.makeImage(for: inviteURL) else { return nil }
let renderer = ImageRenderer(
content: InvitePosterView(
qrImage: qrImage,
language: language,
inviterName: inviterName,
colorScheme: colorScheme,
shortCode: shortCode
)
)
renderer.scale = exportScale
return renderer.cgImage
}
}
@@ -0,0 +1,62 @@
// InviteQRFullScreenView.swift
// OSGKeyboard · Main App
//
// Face-to-face mode for the invitation: only the QR code, as large as the
// screen allows, on white at full brightness so a friend's camera locks on
// from arm's length. Tapping anywhere closes it.
import OSGKeyboardShared
import SwiftUI
struct InviteQRFullScreenView: View {
@Environment(\.dismiss) private var dismiss
let qrImage: CGImage
let language: AppUILanguage?
/// Past this size a phone held at arm's length no longer gains anything.
private static let maxCodeSide: CGFloat = 440
var body: some View {
GeometryReader { proxy in
let side = min(
min(proxy.size.width, proxy.size.height) - Spacing.xxl * 2,
Self.maxCodeSide
)
VStack(spacing: Spacing.lg) {
Image(decorative: qrImage, scale: 1)
.interpolation(.none)
.resizable()
.scaledToFit()
.frame(width: side, height: side)
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
Text(SharedL10n.string("invite.poster.hint", language: language))
.font(TypeStyle.headline)
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.background(OSGColor.fixedLightContent.ignoresSafeArea())
.overlay(alignment: .topTrailing) {
Button {
dismiss()
} label: {
Image(systemName: "xmark")
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(OSGColor.fixedDarkContent.opacity(0.6))
.frame(width: 44, height: 44)
}
.buttonStyle(.plain)
.padding(Spacing.md)
.accessibilityLabel(AppL10n.string("common.done"))
.accessibilityIdentifier("account.referral.poster.fullScreen.close")
}
.contentShape(Rectangle())
.onTapGesture { dismiss() }
.boostsScreenBrightness()
.statusBarHidden()
.accessibilityAction(.escape) { dismiss() }
.accessibilityIdentifier("account.referral.poster.fullScreen")
}
}
@@ -0,0 +1,306 @@
// InviteShareSheet.swift
// OSGKeyboard · Main App
//
// Opened by the Invite button. Shows the invitation poster, which opens a
// full-screen code for face-to-face scanning when tapped, plus the three ways an
// invitation usually leaves the phone: saving the image to send in a chat app,
// the system share sheet, or the plain link.
import OSGKeyboardShared
import Photos
import SwiftUI
import UIKit
struct InviteShareSheet: View {
@Environment(\.themePalette) private var palette
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject private var coordinator: AccountSessionCoordinator
@ObservedObject private var config = ProviderConfig.shared
let invitationURL: URL
/// Display form (`XXXX-XXXX`), printed on the poster when available.
var shortCode: String?
@ObservedObject var referralProfile: ReferralProfileViewModel
@State private var poster: UIImage?
@State private var qrImage: CGImage?
@State private var isShowingFullScreenCode = false
@State private var isSharingPoster = false
@State private var isSaving = false
@State private var status: Status?
private enum Status: Equatable {
case success(messageKey: String)
case failure(messageKey: String)
}
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Spacing.lg) {
posterPreview
actions
statusMessage
}
.padding(Spacing.lg)
.frame(maxWidth: .infinity)
}
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("account.referral.poster.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button(AppL10n.string("common.done")) { dismiss() }
.tint(palette.textPrimary)
}
}
}
.task(id: posterRenderID) {
qrImage = InviteQRCode.makeImage(for: invitationURL)
poster = InvitePosterRenderer
.render(
inviteURL: invitationURL,
shortCode: resolvedShortCode,
inviterName: inviterName,
language: config.uiLanguage,
colorScheme: colorScheme
)
.map { UIImage(cgImage: $0) }
}
.task {
// A cached pre-short-code profile may be visible for the first
// frame. Refresh when the sheet opens and rerender once the server
// supplies the account's assigned alias.
guard shortCode == nil else { return }
await referralProfile.refresh()
}
.fullScreenCover(isPresented: $isShowingFullScreenCode) {
if let qrImage {
InviteQRFullScreenView(qrImage: qrImage, language: config.uiLanguage)
}
}
.sheet(isPresented: $isSharingPoster) {
if let poster {
InvitePosterActivityView(poster: poster)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
}
.accessibilityIdentifier("account.referral.poster")
}
/// Tapping the poster opens the full-screen code for face-to-face scanning.
private var posterPreview: some View {
let shape = RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
return Button {
isShowingFullScreenCode = true
} label: {
Group {
if let poster {
Image(uiImage: poster)
.resizable()
.scaledToFit()
} else {
ZStack {
colorScheme == .dark
? OSGColor.fixedDarkContent
: OSGColor.fixedLightContent
ProgressView()
.tint(
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
)
}
}
}
.aspectRatio(
InvitePosterView.size.width / InvitePosterView.size.height,
contentMode: .fit
)
.overlay(alignment: .bottomTrailing) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(posterPrimaryContent)
.frame(width: 32, height: 32)
.background(posterPrimaryContent.opacity(0.16), in: Circle())
.padding(Spacing.sm)
.accessibilityHidden(true)
}
.clipShape(shape)
}
.buttonStyle(.plain)
.disabled(qrImage == nil)
.frame(maxWidth: 320)
.cardElevation()
.accessibilityLabel(AppL10n.string("account.referral.poster.accessibility"))
.accessibilityHint(AppL10n.string("account.referral.poster.enlargeHint"))
.accessibilityIdentifier("account.referral.poster.preview")
}
private var inviterName: String? {
guard case let .signedIn(session) = coordinator.sessionPhase else { return nil }
return session.displayName
}
private var resolvedShortCode: String? {
if let shortCode { return shortCode }
guard case let .loaded(profile) = referralProfile.state else { return nil }
return profile.code.displayShortCode
}
private var posterRenderID: String {
"\(colorScheme)-\(resolvedShortCode ?? "")-\(inviterName ?? "")"
}
private var posterPrimaryContent: Color {
colorScheme == .dark
? OSGColor.fixedLightContent
: OSGColor.fixedDarkContent
}
private var actions: some View {
VStack(spacing: Spacing.sm) {
Button {
Task { await savePoster() }
} label: {
Group {
if isSaving {
ProgressView()
.tint(palette.textOnAccent)
} else {
Label(
AppL10n.string("account.referral.poster.save"),
systemImage: "square.and.arrow.down"
)
}
}
.primaryButton()
}
.buttonStyle(.plain)
.disabled(poster == nil || isSaving)
.accessibilityIdentifier("account.referral.poster.save")
HStack(spacing: Spacing.sm) {
Button {
isSharingPoster = true
} label: {
Label(
AppL10n.string("account.referral.poster.share"),
systemImage: "square.and.arrow.up"
)
.secondaryButton()
}
.buttonStyle(.plain)
.disabled(poster == nil)
.accessibilityIdentifier("account.referral.poster.share")
Button(action: copyLink) {
Label(
AppL10n.string("account.referral.poster.copyLink"),
systemImage: "link"
)
.secondaryButton()
}
.buttonStyle(.plain)
.accessibilityIdentifier("account.referral.poster.copyLink")
}
}
.frame(maxWidth: 360)
}
@ViewBuilder
private var statusMessage: some View {
switch status {
case .success(let messageKey):
Label(AppL10n.string(messageKey), systemImage: "checkmark.circle.fill")
.font(TypeStyle.footnote)
.foregroundStyle(palette.success)
case .failure(let messageKey):
Label(AppL10n.string(messageKey), systemImage: "exclamationmark.triangle.fill")
.font(TypeStyle.footnote)
.foregroundStyle(palette.danger)
.multilineTextAlignment(.center)
case nil:
Text(AppL10n.string("account.referral.poster.footnote"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textTertiary)
.multilineTextAlignment(.center)
}
}
private func savePoster() async {
guard let poster, !isSaving else { return }
isSaving = true
defer { isSaving = false }
// PhotoKit terminates the process instead of throwing when this usage
// description is missing from the built app. Keep this preflight so a
// future project-generation regression becomes a visible error.
guard let usageDescription = Bundle.main.object(
forInfoDictionaryKey: "NSPhotoLibraryAddUsageDescription"
) as? String, !usageDescription.isEmpty else {
status = .failure(messageKey: "account.referral.poster.saveFailed")
return
}
// Add-only access: the app never needs to read the user's library.
let authorization = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
guard authorization == .authorized || authorization == .limited else {
status = .failure(messageKey: "account.referral.poster.saveDenied")
return
}
guard let pngData = poster.pngData() else {
status = .failure(messageKey: "account.referral.poster.saveFailed")
return
}
do {
try await Self.addToPhotos(pngData: pngData)
status = .success(messageKey: "account.referral.poster.saved")
AnalyticsHostService.shared.client.recordReferralShared()
} catch {
status = .failure(messageKey: "account.referral.poster.saveFailed")
}
}
/// Photos runs the change block on its own transaction queue. It must be
/// built outside the view's main-actor isolation: Swift 6 traps when a
/// main-actor closure runs off the main thread.
private nonisolated static func addToPhotos(pngData: Data) async throws {
try await PHPhotoLibrary.shared().performChanges {
PHAssetCreationRequest.forAsset().addResource(with: .photo, data: pngData, options: nil)
}
}
private func copyLink() {
UIPasteboard.general.string = invitationURL.absoluteString
status = .success(messageKey: "account.referral.poster.linkCopied")
AnalyticsHostService.shared.client.recordReferralShared()
}
}
/// Shares the poster image only. Chat apps' share extensions often refuse a
/// mixed image + URL payload, and the link is one tap away via Copy Link.
private struct InvitePosterActivityView: UIViewControllerRepresentable {
let poster: UIImage
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(
activityItems: [poster],
applicationActivities: nil
)
controller.completionWithItemsHandler = { _, completed, _, _ in
guard completed else { return }
Task { @MainActor in
AnalyticsHostService.shared.client.recordReferralShared()
}
}
return controller
}
func updateUIViewController(
_ uiViewController: UIActivityViewController,
context: Context
) {}
}
@@ -0,0 +1,288 @@
// ReferralCodeEntry.swift
// OSGKeyboard · Main App
//
// Manual path for invitations that never opened the app directly. A friend
// who scans a poster usually installs from the App Store, so the Universal
// Link does not fire; instead they type the 8-character code printed on the
// poster and the landing page. Pasting the full link into the field works
// too. The clipboard is never read on its own.
import OSGKeyboardShared
import SwiftUI
/// Green "I have a code" button, styled like the Invite button. The caller
/// owns `isPresented` so it can keep the button on screen while the sheet is
/// open, even if a successful redemption closes the binding window.
struct ReferralCodeEntryButton: View {
@Binding var isPresented: Bool
/// Signed-in accounts also get an Invite entry at the bottom of the sheet.
var invitation: ReferralCode?
var body: some View {
Button {
isPresented = true
} label: {
AccountReferralActionLabel(
titleKey: "account.referral.codeEntry.button",
systemImage: "gift"
)
}
.buttonStyle(.plain)
.accessibilityIdentifier("account.referral.codeEntry")
.sheet(isPresented: $isPresented) {
ReferralCodeEntrySheet(invitation: invitation)
}
}
}
/// Text field + confirm. Outcomes are shown inline rather than through the
/// global alert; success closes the sheet after a short confirmation.
struct ReferralCodeEntrySheet: View {
typealias Outcome = AccountSessionCoordinator.ReferralInputOutcome
@Environment(\.dismiss) private var dismiss
@Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
var invitation: ReferralCode?
@State private var text = ""
@State private var isSubmitting = false
@State private var outcome: Outcome?
@FocusState private var isFieldFocused: Bool
/// Long enough to read the confirmation before the sheet closes.
private static let successDismissDelay: Duration = .milliseconds(1_200)
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: Spacing.md) {
Text(AppL10n.string("account.referral.codeEntry.sheetBody"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
codeField
confirmButton
feedback
if coordinator.isSignedIn, let invitation {
inviteMore(invitation)
}
}
.padding(.horizontal, Spacing.lg)
.padding(.vertical, Spacing.md)
}
.scrollBounceBehavior(.basedOnSize)
.background(palette.background.ignoresSafeArea())
.navigationTitle(AppL10n.string("account.referral.codeEntry.sheetTitle"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(AppL10n.string("common.cancel")) { dismiss() }
.tint(palette.textPrimary)
}
}
.onAppear { isFieldFocused = true }
}
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.interactiveDismissDisabled(isSubmitting)
.accessibilityIdentifier("account.referral.codeEntry.sheet")
}
private var codeField: some View {
TextField(AppL10n.string("account.referral.codeEntry.placeholder"), text: $text)
.font(.system(.title3, design: .monospaced, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.tint(palette.textPrimary)
.focused($isFieldFocused)
// Codes are uppercase; links pasted into the field are unaffected.
.textInputAutocapitalization(.characters)
.autocorrectionDisabled()
.keyboardType(.asciiCapable)
.submitLabel(.done)
.onSubmit { Task { await submit() } }
.onChange(of: text) { _, _ in outcome = nil }
.settingsListRow()
.background(
palette.formSurface,
in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
)
.accessibilityLabel(AppL10n.string("account.referral.codeEntry.fieldLabel"))
.accessibilityIdentifier("account.referral.codeEntry.field")
}
private var confirmButton: some View {
Button {
Task { await submit() }
} label: {
Group {
if isSubmitting {
ProgressView()
.tint(OSGColor.fixedLightContent)
.frame(maxWidth: .infinity, minHeight: 44)
.background(
palette.success,
in: RoundedRectangle(cornerRadius: Radius.medium, style: .continuous)
)
} else {
AccountReferralActionLabel(
titleKey: "account.referral.codeEntry.confirm",
systemImage: "checkmark",
fillsWidth: true
)
}
}
}
.buttonStyle(.plain)
.disabled(!canSubmit)
.opacity(canSubmit || isSubmitting ? 1 : 0.5)
.accessibilityIdentifier("account.referral.codeEntry.confirm")
}
@ViewBuilder
private var feedback: some View {
switch outcome {
case .invalidInput:
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.invalid", isSuccess: false)
case .failed(let messageKey):
ReferralFeedbackLabel(messageKey: messageKey, isSuccess: false)
case .redeemed:
ReferralFeedbackLabel(messageKey: "account.referral.codeEntry.redeemed", isSuccess: true)
case .savedForSignIn:
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
case nil:
EmptyView()
}
}
/// The "invite in the sheet" decision: during the binding window the card
/// shows "I have a code" instead of Invite, so inviting stays one tap away.
private func inviteMore(_ invitation: ReferralCode) -> some View {
VStack(alignment: .leading, spacing: Spacing.md) {
Divider().background(palette.divider)
HStack(spacing: Spacing.md) {
Text(AppL10n.string("account.referral.codeEntry.inviteMore"))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
Spacer(minLength: Spacing.xs)
AccountInvitationButton(
invitationURL: invitation.inviteURL,
shortCode: invitation.displayShortCode
)
}
}
.padding(.top, Spacing.sm)
}
private var canSubmit: Bool {
!isSubmitting
&& coordinator.operation == nil
&& !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func submit() async {
guard canSubmit else { return }
isSubmitting = true
let result = await coordinator.submitReferralInput(text)
isSubmitting = false
outcome = result
guard result == .redeemed || result == .savedForSignIn else { return }
isFieldFocused = false
try? await Task.sleep(for: Self.successDismissDelay)
dismiss()
}
}
/// "Have an invitation?" row. Signed out it sits under the Apple button on
/// Home and in the account center, and the code is held until sign-in.
struct ReferralCodeEntryRow: View {
@Environment(\.themePalette) private var palette
@EnvironmentObject private var coordinator: AccountSessionCoordinator
var invitation: ReferralCode?
/// Pass a binding when the parent must know the sheet is open.
var isEntryPresented: Binding<Bool>?
@State private var isLocallyPresented = false
private var presentation: Binding<Bool> {
isEntryPresented ?? $isLocallyPresented
}
var body: some View {
VStack(alignment: .leading, spacing: Spacing.xs) {
HStack(alignment: .center, spacing: Spacing.sm) {
VStack(alignment: .leading, spacing: Spacing.xxs) {
Text(AppL10n.string("account.referral.codeEntry.title"))
.font(TypeStyle.bodyEmph)
.foregroundStyle(palette.textPrimary)
Text(AppL10n.string(
coordinator.isSignedIn
? "account.referral.codeEntry.bodySignedIn"
: "account.referral.codeEntry.body"
))
.font(TypeStyle.footnote)
.foregroundStyle(palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
ReferralCodeEntryButton(isPresented: presentation, invitation: invitation)
.disabled(coordinator.operation != nil)
}
if coordinator.pendingReferralCode != nil, !coordinator.isSignedIn {
ReferralFeedbackLabel(messageKey: "account.referral.pendingAfterSignIn", isSuccess: true)
}
}
}
}
/// Signed-in variant for the account center: only accounts that have not
/// bound an inviter and are still inside the binding window see it.
struct SignedInReferralCodeCard: View {
@ObservedObject var profile: ReferralProfileViewModel
let session: AccountSession
@State private var isEntryPresented = false
var body: some View {
if isEntryPresented || isEligible {
ReferralCodeEntryRow(invitation: loadedCode, isEntryPresented: $isEntryPresented)
.padding(Spacing.md)
.surfaceCard()
.accessibilityIdentifier("account.referral.codeEntryCard")
}
}
private var loadedCode: ReferralCode? {
guard case let .loaded(loaded) = profile.state else { return nil }
return loaded.code
}
private var isEligible: Bool {
guard case let .loaded(loaded) = profile.state else { return false }
return ReferralBindingWindow.isOpen(for: session, profile: loaded)
}
}
struct ReferralFeedbackLabel: View {
@Environment(\.themePalette) private var palette
let messageKey: String
let isSuccess: Bool
var body: some View {
Label(
AppL10n.string(messageKey),
systemImage: isSuccess ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"
)
.font(TypeStyle.footnote)
.foregroundStyle(isSuccess ? palette.success : palette.danger)
.fixedSize(horizontal: false, vertical: true)
}
}
+1
View File
@@ -5,3 +5,4 @@
"NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number."; "NSContactsUsageDescription" = "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number.";
"NSRemindersFullAccessUsageDescription" = "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text."; "NSRemindersFullAccessUsageDescription" = "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text.";
"NSCalendarsWriteOnlyAccessUsageDescription" = "OSGKeyboard adds calendar events only when you choose Extract Events for copied text. It never reads your existing events."; "NSCalendarsWriteOnlyAccessUsageDescription" = "OSGKeyboard adds calendar events only when you choose Extract Events for copied text. It never reads your existing events.";
"NSPhotoLibraryAddUsageDescription" = "OSGKeyboard saves your invitation poster to Photos only when you choose Save Image.";
+29
View File
@@ -983,6 +983,35 @@
"account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in."; "account.referral.pendingAfterSignIn" = "Your invitation is saved and will be redeemed after sign-in.";
"account.referral.rewardGranted" = "You received %@ credits from the invitation."; "account.referral.rewardGranted" = "You received %@ credits from the invitation.";
"account.referral.rewardGrantedGeneric" = "Your invitation reward has been added to your balance."; "account.referral.rewardGrantedGeneric" = "Your invitation reward has been added to your balance.";
"account.referral.poster.title" = "Invite Friends";
"account.referral.poster.accessibility" = "Invitation poster with a QR code for your invitation link";
"account.referral.poster.enlargeHint" = "Shows the QR code full screen for scanning in person.";
"account.referral.poster.save" = "Save Image";
"account.referral.poster.share" = "Share";
"account.referral.poster.copyLink" = "Copy Link";
"account.referral.poster.footnote" = "Send the image to a friend, or tap the poster to show a large code to scan in person. Once your friend signs up and completes a first qualifying use, you both earn credits.";
"account.referral.poster.saved" = "Saved to Photos";
"account.referral.poster.saveDenied" = "Allow OSGKeyboard to add photos in Settings, then try again.";
"account.referral.poster.saveFailed" = "Couldn’t save the image. Please try again.";
"account.referral.poster.linkCopied" = "Invitation link copied";
"account.referral.codeEntry.title" = "Have an invitation?";
"account.referral.codeEntry.body" = "Enter your friend’s code, then sign in to accept it.";
"account.referral.codeEntry.bodySignedIn" = "Enter your friend’s code and you both earn credits.";
"account.referral.codeEntry.button" = "I have a code";
"account.referral.codeEntry.windowDescription" = "Enter a code, or invite more friends for credits";
"account.referral.codeEntry.sheetTitle" = "Enter Invitation Code";
"account.referral.codeEntry.sheetBody" = "Enter the 8-character code from your friend’s poster or invitation page, or paste the invitation link.";
"account.referral.codeEntry.placeholder" = "e.g. K7M2-Q9XA";
"account.referral.codeEntry.fieldLabel" = "Invitation code";
"account.referral.codeEntry.confirm" = "Confirm";
"account.referral.codeEntry.invalid" = "That doesn’t look like an invitation code. Check it and try again.";
"account.referral.codeEntry.redeemed" = "Invitation accepted. You both earn credits after qualifying usage.";
"account.referral.codeEntry.inviteMore" = "You can also invite more friends for credits";
"account.referral.redeem.error.invalid" = "This invitation code is invalid, or the campaign has ended.";
"account.referral.redeem.error.expired" = "The time to add an invitation to this account has passed.";
"account.referral.redeem.error.self" = "You can’t use your own invitation.";
"account.referral.redeem.error.alreadyBound" = "This account has already accepted another invitation.";
"account.referral.redeem.error.ineligible" = "This account can’t accept this invitation.";
"account.security.section" = "Account controls"; "account.security.section" = "Account controls";
"account.signOut.action" = "Sign Out"; "account.signOut.action" = "Sign Out";
"account.signOut.confirmTitle" = "Sign out?"; "account.signOut.confirmTitle" = "Sign out?";
@@ -5,3 +5,4 @@
"NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。"; "NSContactsUsageDescription" = "仅当你为复制的电话号码选择“新建联系人”时,OSGKeyboard 才会使用通讯录。";
"NSRemindersFullAccessUsageDescription" = "仅当你为复制的文本选择“提取待办”时,OSGKeyboard 才会添加提醒事项。"; "NSRemindersFullAccessUsageDescription" = "仅当你为复制的文本选择“提取待办”时,OSGKeyboard 才会添加提醒事项。";
"NSCalendarsWriteOnlyAccessUsageDescription" = "仅当你为复制的文本选择“提取日程”时,OSGKeyboard 才会添加日历事件,不会读取你已有的日程。"; "NSCalendarsWriteOnlyAccessUsageDescription" = "仅当你为复制的文本选择“提取日程”时,OSGKeyboard 才会添加日历事件,不会读取你已有的日程。";
"NSPhotoLibraryAddUsageDescription" = "仅当你选择“保存图片”时,OSGKeyboard 才会把邀请海报存入相册。";
@@ -983,6 +983,35 @@
"account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。"; "account.referral.pendingAfterSignIn" = "邀请已暂存,登录后会自动兑换。";
"account.referral.rewardGranted" = "恭喜获得 %@ 积分"; "account.referral.rewardGranted" = "恭喜获得 %@ 积分";
"account.referral.rewardGrantedGeneric" = "邀请奖励已发放,积分已到账。"; "account.referral.rewardGrantedGeneric" = "邀请奖励已发放,积分已到账。";
"account.referral.poster.title" = "邀请好友";
"account.referral.poster.accessibility" = "带有你的邀请链接二维码的邀请海报";
"account.referral.poster.enlargeHint" = "全屏显示二维码,方便当面扫码。";
"account.referral.poster.save" = "保存图片";
"account.referral.poster.share" = "分享";
"account.referral.poster.copyLink" = "复制链接";
"account.referral.poster.footnote" = "把图片发给好友,或点海报放大二维码当面扫。好友注册并完成首次有效使用后,你们都能获得积分。";
"account.referral.poster.saved" = "已保存到相册";
"account.referral.poster.saveDenied" = "请在“设置”中允许 OSGKeyboard 添加照片,然后重试。";
"account.referral.poster.saveFailed" = "图片保存失败,请重试。";
"account.referral.poster.linkCopied" = "邀请链接已复制";
"account.referral.codeEntry.title" = "有好友邀请?";
"account.referral.codeEntry.body" = "输入好友的邀请码后登录,即可接受邀请";
"account.referral.codeEntry.bodySignedIn" = "输入好友的邀请码,你们都能获得积分。";
"account.referral.codeEntry.button" = "我有邀请码";
"account.referral.codeEntry.windowDescription" = "输入邀请码,或邀请更多好友获取积分";
"account.referral.codeEntry.sheetTitle" = "输入邀请码";
"account.referral.codeEntry.sheetBody" = "输入好友海报或邀请页上的 8 位邀请码,也可以直接粘贴邀请链接。";
"account.referral.codeEntry.placeholder" = "例如 K7M2-Q9XA";
"account.referral.codeEntry.fieldLabel" = "邀请码";
"account.referral.codeEntry.confirm" = "确认";
"account.referral.codeEntry.invalid" = "没有识别到邀请码,请核对后重试。";
"account.referral.codeEntry.redeemed" = "已接受好友邀请,完成一次有效使用后双方均可获得积分。";
"account.referral.codeEntry.inviteMore" = "也可以邀请更多好友获取积分";
"account.referral.redeem.error.invalid" = "邀请码无效,或活动已结束。";
"account.referral.redeem.error.expired" = "这个账号已超过补填邀请的期限。";
"account.referral.redeem.error.self" = "不能使用自己的邀请。";
"account.referral.redeem.error.alreadyBound" = "这个账号已经接受过其他好友的邀请。";
"account.referral.redeem.error.ineligible" = "这个账号暂时不能接受该邀请。";
"account.security.section" = "账号操作"; "account.security.section" = "账号操作";
"account.signOut.action" = "退出登录"; "account.signOut.action" = "退出登录";
"account.signOut.confirmTitle" = "退出登录?"; "account.signOut.confirmTitle" = "退出登录?";
+130 -5
View File
@@ -18,6 +18,7 @@
// is server-authoritative either way. // is server-authoritative either way.
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
#if os(macOS) #if os(macOS)
import AppKit import AppKit
#endif #endif
@@ -46,7 +47,11 @@ struct MacAccountSettingsSection: View {
case let .signedIn(session): case let .signedIn(session):
identityRow(session) identityRow(session)
creditRows creditRows
MacInviteLinkRow(profile: coordinator.referralProfile, language: lang) MacInviteLinkRow(
profile: coordinator.referralProfile,
inviterName: session.displayName,
language: lang
)
referralCountRows referralCountRows
accountActionsRow accountActionsRow
deleteRows deleteRows
@@ -58,9 +63,7 @@ struct MacAccountSettingsSection: View {
MacL10n.string("mac.account.error.title", language: lang), MacL10n.string("mac.account.error.title", language: lang),
isPresented: operationErrorBinding isPresented: operationErrorBinding
) { ) {
Button(MacL10n.string("mac.account.error.dismiss", language: lang)) { Button(MacL10n.string("mac.account.error.dismiss", language: lang)) {}
coordinator.dismissOperationError()
}
} message: { } message: {
if let key = coordinator.operationErrorKey { if let key = coordinator.operationErrorKey {
// The code is appended verbatim: on Mac there is no TestFlight // The code is appended verbatim: on Mac there is no TestFlight
@@ -268,7 +271,12 @@ struct MacAccountSettingsSection: View {
Binding( Binding(
get: { coordinator.operationErrorKey != nil }, get: { coordinator.operationErrorKey != nil },
set: { isPresented in set: { isPresented in
if !isPresented { coordinator.dismissOperationError() } guard !isPresented else { return }
// SwiftUI writes this binding while updating the alert.
// Defer ObservableObject mutations until that update ends.
Task { @MainActor in
coordinator.dismissOperationError()
}
} }
) )
} }
@@ -283,10 +291,12 @@ private struct MacInviteLinkRow: View {
@ObservedObject var profile: ReferralProfileViewModel @ObservedObject var profile: ReferralProfileViewModel
@Environment(\.themePalette) private var palette @Environment(\.themePalette) private var palette
let inviterName: String?
let language: AppUILanguage let language: AppUILanguage
@State private var didCopy = false @State private var didCopy = false
@State private var copyResetTask: Task<Void, Never>? @State private var copyResetTask: Task<Void, Never>?
@State private var isShowingPoster = false
var body: some View { var body: some View {
content content
@@ -312,6 +322,28 @@ private struct MacInviteLinkRow: View {
title: MacL10n.string("mac.account.invite.link", language: language), title: MacL10n.string("mac.account.invite.link", language: language),
subtitle: loaded.code.inviteURL.absoluteString subtitle: loaded.code.inviteURL.absoluteString
) { ) {
HStack(spacing: Spacing.md) {
Button {
isShowingPoster = true
} label: {
Label(
MacL10n.string("mac.account.invite.qr", language: language),
systemImage: "qrcode"
)
.font(MacSettingsType.rowLabel)
}
.buttonStyle(.plain)
.foregroundStyle(palette.accent)
.accessibilityIdentifier("mac.account.invite.qr")
.popover(isPresented: $isShowingPoster, arrowEdge: .bottom) {
MacInvitePosterPopover(
inviteURL: loaded.code.inviteURL,
shortCode: loaded.code.displayShortCode,
inviterName: inviterName,
language: language
)
}
Button { Button {
copy(loaded.code.inviteURL) copy(loaded.code.inviteURL)
} label: { } label: {
@@ -330,6 +362,7 @@ private struct MacInviteLinkRow: View {
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
.accessibilityIdentifier("mac.account.invite.copy") .accessibilityIdentifier("mac.account.invite.copy")
} }
}
case let .failed(messageKey): case let .failed(messageKey):
MacInlineRow( MacInlineRow(
@@ -361,3 +394,95 @@ private struct MacInviteLinkRow: View {
} }
} }
} }
/// The same poster as iOS. On a Mac it is mostly scanned straight off the
/// screen with a phone, or saved as a PNG to send from a chat app.
private struct MacInvitePosterPopover: View {
@Environment(\.themePalette) private var palette
@Environment(\.colorScheme) private var colorScheme
let inviteURL: URL
let shortCode: String?
let inviterName: String?
let language: AppUILanguage
@State private var poster: CGImage?
@State private var saveFailed = false
var body: some View {
VStack(spacing: Spacing.md) {
Group {
if let poster {
Image(decorative: poster, scale: InvitePosterRenderer.exportScale)
.resizable()
.scaledToFit()
} else {
ProgressView().controlSize(.small)
}
}
.frame(width: 270, height: 360)
.clipShape(RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
// Selectable so it can be copied into a chat on the Mac.
if let shortCode {
Text(SharedL10n.format("invite.poster.code", language: language, shortCode))
.font(.system(.body, design: .monospaced, weight: .semibold))
.foregroundStyle(palette.textPrimary)
.textSelection(.enabled)
.accessibilityIdentifier("mac.account.invite.shortCode")
}
Text(MacL10n.string("mac.account.invite.qrHint", language: language))
.font(MacSettingsType.hint)
.foregroundStyle(palette.textSecondary)
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
Button(MacL10n.string("mac.account.invite.savePoster", language: language)) {
savePoster()
}
.disabled(poster == nil)
.accessibilityIdentifier("mac.account.invite.savePoster")
if saveFailed {
Text(MacL10n.string("mac.account.invite.saveFailed", language: language))
.font(MacSettingsType.hint)
.foregroundStyle(palette.danger)
}
}
.padding(Spacing.lg)
.frame(width: 310)
.task(id: colorScheme) {
poster = InvitePosterRenderer.render(
inviteURL: inviteURL,
shortCode: shortCode,
inviterName: inviterName,
language: language,
colorScheme: colorScheme
)
}
}
private func savePoster() {
#if os(macOS)
guard let poster else { return }
let panel = NSSavePanel()
panel.allowedContentTypes = [.png]
panel.canCreateDirectories = true
panel.nameFieldStringValue = "OSGKeyboard-Invite.png"
guard panel.runModal() == .OK, let url = panel.url else { return }
let bitmap = NSBitmapImageRep(cgImage: poster)
guard let data = bitmap.representation(using: .png, properties: [:]) else {
saveFailed = true
return
}
do {
try data.write(to: url, options: .atomic)
saveFailed = false
} catch {
saveFailed = true
}
#endif
}
}
@@ -17,6 +17,13 @@ public enum OSGColor {
public static let fixedLightContent = Color.white public static let fixedLightContent = Color.white
public static let fixedDarkContent = Color.black public static let fixedDarkContent = Color.black
// MARK: - Invitation poster
/// Shared artwork must look identical for every recipient, so these do not
/// follow the app appearance. The background matches the app icon.
public static let invitePosterBackground = color(0x252527)
public static let invitePosterHint = color(0xA1A1A6)
// MARK: - iOS app theme // MARK: - iOS app theme
public static let darkBackground = color(0x0A0A0B) public static let darkBackground = color(0x0A0A0B)
+15
View File
@@ -498,6 +498,16 @@
"mac.account.invite.rewarded" = "Rewarded invites"; "mac.account.invite.rewarded" = "Rewarded invites";
"mac.account.invite.pending" = "Pending invites"; "mac.account.invite.pending" = "Pending invites";
"mac.account.invite.footnote" = "Both of you get credits once an invited friend qualifies."; "mac.account.invite.footnote" = "Both of you get credits once an invited friend qualifies.";
"mac.account.invite.qr" = "QR Code";
"mac.account.invite.qrHint" = "Scan with a phone, or save the poster and send it to a friend.";
"mac.account.invite.savePoster" = "Save Poster…";
"mac.account.invite.saveFailed" = "Couldn’t save the poster.";
"invite.poster.slogan" = "Your AI voice double";
"invite.poster.hint" = "Scan to try it and earn credits";
"invite.poster.defaultInviter" = "OSG User";
"invite.poster.inviter" = "%@ invites you to try\nOSG AI Keyboard";
"invite.poster.scan" = "Scan to download and enter the code to earn credits";
"invite.poster.code" = "Invitation code: %@";
"mac.account.delete.title" = "Delete Account"; "mac.account.delete.title" = "Delete Account";
"mac.account.delete.body" = "Deleting removes your account and any remaining credits. This cannot be undone."; "mac.account.delete.body" = "Deleting removes your account and any remaining credits. This cannot be undone.";
"mac.account.delete.action" = "Delete Account…"; "mac.account.delete.action" = "Delete Account…";
@@ -523,6 +533,11 @@
"account.referral.error.notFound" = "Your invitation link isn’t available yet."; "account.referral.error.notFound" = "Your invitation link isn’t available yet.";
"account.referral.error.session" = "Sign in again to load your invitation link."; "account.referral.error.session" = "Sign in again to load your invitation link.";
"account.referral.error.unavailable" = "The invitation service is temporarily unavailable."; "account.referral.error.unavailable" = "The invitation service is temporarily unavailable.";
"account.referral.redeem.error.invalid" = "This invitation link is invalid, or the campaign has ended.";
"account.referral.redeem.error.expired" = "The time to add an invitation to this account has passed.";
"account.referral.redeem.error.self" = "You can’t use your own invitation.";
"account.referral.redeem.error.alreadyBound" = "This account has already accepted another invitation.";
"account.referral.redeem.error.ineligible" = "This account can’t accept this invitation.";
/* Rime / typing engine errors */ /* Rime / typing engine errors */
"error.rime.appGroupUnavailable" = "App Group unavailable."; "error.rime.appGroupUnavailable" = "App Group unavailable.";
@@ -498,6 +498,16 @@
"mac.account.invite.rewarded" = "已奖励邀请"; "mac.account.invite.rewarded" = "已奖励邀请";
"mac.account.invite.pending" = "待生效邀请"; "mac.account.invite.pending" = "待生效邀请";
"mac.account.invite.footnote" = "受邀好友达成条件后,双方都可获得积分。"; "mac.account.invite.footnote" = "受邀好友达成条件后,双方都可获得积分。";
"mac.account.invite.qr" = "二维码";
"mac.account.invite.qrHint" = "用手机扫码,或保存海报发给好友。";
"mac.account.invite.savePoster" = "保存海报…";
"mac.account.invite.saveFailed" = "海报保存失败。";
"invite.poster.slogan" = "你的 AI 嘴替";
"invite.poster.hint" = "扫一扫 · 下载体验领积分";
"invite.poster.defaultInviter" = "OSG 用户";
"invite.poster.inviter" = "%@ 邀你体验\nOSG AI 输入法";
"invite.poster.scan" = "扫码下载,输入邀请码获取积分";
"invite.poster.code" = "邀请码:%@";
"mac.account.delete.title" = "注销账号"; "mac.account.delete.title" = "注销账号";
"mac.account.delete.body" = "注销会删除你的账号及剩余积分,且无法撤销。"; "mac.account.delete.body" = "注销会删除你的账号及剩余积分,且无法撤销。";
"mac.account.delete.action" = "注销账号…"; "mac.account.delete.action" = "注销账号…";
@@ -523,6 +533,11 @@
"account.referral.error.notFound" = "邀请链接暂不可用。"; "account.referral.error.notFound" = "邀请链接暂不可用。";
"account.referral.error.session" = "请重新登录后加载邀请链接。"; "account.referral.error.session" = "请重新登录后加载邀请链接。";
"account.referral.error.unavailable" = "邀请服务暂时不可用。"; "account.referral.error.unavailable" = "邀请服务暂时不可用。";
"account.referral.redeem.error.invalid" = "邀请链接无效,或活动已结束。";
"account.referral.redeem.error.expired" = "这个账号已超过补填邀请的期限。";
"account.referral.redeem.error.self" = "不能使用自己的邀请。";
"account.referral.redeem.error.alreadyBound" = "这个账号已经接受过其他好友的邀请。";
"account.referral.redeem.error.ineligible" = "这个账号暂时不能接受该邀请。";
/* 输入法引擎错误 */ /* 输入法引擎错误 */
"error.rime.appGroupUnavailable" = "App Group 不可用。"; "error.rime.appGroupUnavailable" = "App Group 不可用。";
@@ -5,6 +5,7 @@
import AuthenticationServices import AuthenticationServices
@testable import OSGKeyboard @testable import OSGKeyboard
@testable import OSGKeyboardHostSupport
import XCTest import XCTest
final class AccountCenterViewModelTests: XCTestCase { final class AccountCenterViewModelTests: XCTestCase {
@@ -34,6 +35,185 @@ final class AccountCenterViewModelTests: XCTestCase {
} }
} }
func testPastedInputAcceptsCodeLinkAndChatMessage() {
let inputs = [
validCode,
" \(validCode)\n",
"https://osglab.com/i/\(validCode)",
"https://osglab.com/i/\(validCode)?source=poster",
"快来试试 OSGKeyboard:https://osglab.com/i/\(validCode)",
"邀请你加入https://osglab.com/i/\(validCode),一起领积分",
"Join me: <https://osglab.com/i/\(validCode)>"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
validCode,
"Failed to extract from \(input)"
)
}
}
func testPastedInputRejectsForeignLinksAndPlainText() {
let inputs = [
"",
"hello",
"https://example.com/i/\(validCode)",
"http://osglab.com/i/\(validCode)",
"https://osglab.com/i/short",
String(validCode.dropLast())
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testTypedInputAcceptsShortCodeInAnyCaseWithOrWithoutHyphen() {
let inputs = [
"K7M2Q9XA",
"k7m2-q9xa",
" K7M2-Q9XA \n",
"邀请码K7M2-Q9XA",
"我的邀请码:K7M2-Q9XA,快来"
]
for input in inputs {
XCTAssertEqual(
ReferralUniversalLink.code(fromUserInput: input),
"K7M2Q9XA",
"Failed to extract from \(input)"
)
}
}
func testTypedInputRejectsAmbiguousOrMalformedShortCodes() {
let inputs = [
"K7M2Q9X",
"K7M2Q9XAB",
"K7M-2Q9XA",
"K0M2Q9XA",
"KOM2Q9XA",
"K1M2Q9XA",
"KLM2Q9XA"
]
for input in inputs {
XCTAssertNil(
ReferralUniversalLink.code(fromUserInput: input),
"Unexpectedly accepted \(input)"
)
}
}
func testInvitationLinkWinsOverShortCodeLikeWordsInTheSameMessage() {
// "HEXAGRAM" uses only short-code characters.
let input = "HEXAGRAM https://osglab.com/i/\(validCode)"
XCTAssertEqual(ReferralUniversalLink.code(fromUserInput: input), validCode)
}
func testShortCodeDisplayAddsHyphen() {
XCTAssertEqual(ReferralShortCode.display("K7M2Q9XA"), "K7M2-Q9XA")
XCTAssertEqual(ReferralShortCode.display("bogus"), "bogus")
}
@MainActor
func testPendingStoreKeepsShortCodesForRedemptionAfterSignIn() throws {
let suiteName = "PendingReferralShortCode.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
defer { defaults.removePersistentDomain(forName: suiteName) }
let store = UserDefaultsPendingReferralCodeStore(defaults: defaults)
store.save("K7M2Q9XA")
XCTAssertEqual(store.code, "K7M2Q9XA")
}
func testBindingWindowFollowsServerDeadlineWhenPresent() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let deadline = Date(timeIntervalSince1970: 1_700_003_600)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: nil,
bindingDeadline: deadline
)
XCTAssertTrue(ReferralBindingWindow.isOpen(for: account, profile: profile, now: deadline))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: deadline.addingTimeInterval(1)
))
}
func testBindingWindowFallsBackToSevenDaysWithoutServerDeadline() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let profile = ReferralProfile(code: try makeReferralCode(), binding: nil)
let start = Date(timeIntervalSince1970: 1_700_000_000)
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
}
func testBindingWindowClosesOnceBound() throws {
let account = AccountSession(accountID: UUID(), createdAtEpochSeconds: 1_700_000_000)
let binding = try JSONDecoder().decode(
ReferralBinding.self,
from: Data(#"{"inviterUserId":"x"}"#.utf8)
)
let profile = ReferralProfile(
code: try makeReferralCode(),
binding: binding,
bindingDeadline: .distantFuture
)
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
profile: profile,
now: Date(timeIntervalSince1970: 1_700_000_001)
))
}
private func makeReferralCode() throws -> ReferralCode {
ReferralCode(
code: validCode,
inviteURL: try XCTUnwrap(URL(string: "https://osglab.com/i/\(validCode)")),
campaignID: nil,
createdAt: Date(timeIntervalSince1970: 1_700_000_000),
shortCode: "K7M2Q9XA"
)
}
func testBindingWindowClosesSevenDaysAfterRegistration() {
let registeredAt: Int64 = 1_700_000_000
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: registeredAt
)
let start = Date(timeIntervalSince1970: TimeInterval(registeredAt))
XCTAssertTrue(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(6 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: account,
now: start.addingTimeInterval(7 * 24 * 60 * 60)
))
XCTAssertFalse(ReferralBindingWindow.isOpen(
for: AccountSession(accountID: UUID(), createdAtEpochSeconds: 0),
now: start
))
}
func testReferralSummaryCountsEveryServerStatus() { func testReferralSummaryCountsEveryServerStatus() {
let referrals = [ let referrals = [
makeReferral(status: .pending), makeReferral(status: .pending),
@@ -439,6 +619,151 @@ final class AccountCenterViewModelTests: XCTestCase {
XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral") XCTAssertEqual(coordinator.operationErrorKey, "account.error.redeemReferral")
} }
@MainActor
func testPermanentRedemptionRejectionsDropTheStoredCode() async {
let cases: [(AccountAPIError, String)] = [
(
.server(statusCode: 404, code: "http_error", message: "Referral code does not exist"),
"account.referral.redeem.error.invalid"
),
(
.server(statusCode: 422, code: "http_error", message: "Referral binding window has expired"),
"account.referral.redeem.error.expired"
),
(
.server(statusCode: 409, code: "http_error", message: "Self-referral is not allowed"),
"account.referral.redeem.error.self"
),
(
.server(
statusCode: 409,
code: "http_error",
message: "This account is already bound to another inviter"
),
"account.referral.redeem.error.alreadyBound"
),
(
.conflict("This account is not eligible for referral rewards"),
"account.referral.redeem.error.ineligible"
)
]
for (error, expectedKey) in cases {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: error
)
let store = InMemoryPendingReferralStore(code: validCode)
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
XCTAssertNil(store.code, "Kept code after \(error)")
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertEqual(coordinator.operationErrorKey, expectedKey)
}
}
@MainActor
func testPastedInvitationWhileSignedOutIsSavedForSignIn() async {
let service = AccountServiceSpy(restoredSession: nil)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let invalid = await coordinator.submitReferralInput("not an invitation")
let saved = await coordinator.submitReferralInput(
"Join me https://osglab.com/i/\(validCode)"
)
XCTAssertEqual(invalid, .invalidInput)
XCTAssertEqual(saved, .savedForSignIn)
XCTAssertEqual(store.code, validCode)
XCTAssertEqual(coordinator.pendingReferralCode, validCode)
XCTAssertFalse(coordinator.consumeAccountCenterPresentation())
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [])
}
@MainActor
func testPastedInvitationWhileSignedInRedeemsImmediately() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .redeemed)
XCTAssertNil(store.code)
XCTAssertNil(coordinator.pendingReferralCode)
XCTAssertNil(coordinator.operationErrorKey)
let redeemedCodes = await service.redeemedCodes()
XCTAssertEqual(redeemedCodes, [validCode])
}
@MainActor
func testPastedInvitationRejectionIsReturnedInsteadOfAlerting() async {
let account = AccountSession(
accountID: UUID(),
createdAtEpochSeconds: 1_700_000_000
)
let service = AccountServiceSpy(
restoredSession: account,
snapshot: makeSnapshot(account: account),
redemptionError: .server(
statusCode: 422,
code: "http_error",
message: "Referral binding window has expired"
)
)
let store = InMemoryPendingReferralStore()
let coordinator = AccountSessionCoordinator(
dependencies: AccountDependencies(
sessionService: service,
centerService: service
),
pendingReferralStore: store
)
await coordinator.restoreIfNeeded()
let outcome = await coordinator.submitReferralInput(validCode)
XCTAssertEqual(outcome, .failed(messageKey: "account.referral.redeem.error.expired"))
XCTAssertNil(store.code)
XCTAssertNil(coordinator.operationErrorKey)
}
@MainActor @MainActor
func testSignOutClearsRemoteAccountStateOnly() async { func testSignOutClearsRemoteAccountStateOnly() async {
let account = AccountSession( let account = AccountSession(
@@ -800,6 +1125,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
private let centerSnapshot: AccountCenterSnapshot? private let centerSnapshot: AccountCenterSnapshot?
private let refreshedSnapshot: AccountCenterSnapshot? private let refreshedSnapshot: AccountCenterSnapshot?
private let shouldFailRedemption: Bool private let shouldFailRedemption: Bool
private let redemptionError: AccountAPIError?
private let shouldFailAccountRefresh: Bool private let shouldFailAccountRefresh: Bool
private let shouldFailSignOut: Bool private let shouldFailSignOut: Bool
private let signOutDelayNanoseconds: UInt64 private let signOutDelayNanoseconds: UInt64
@@ -822,6 +1148,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
snapshot: AccountCenterSnapshot? = nil, snapshot: AccountCenterSnapshot? = nil,
refreshedSnapshot: AccountCenterSnapshot? = nil, refreshedSnapshot: AccountCenterSnapshot? = nil,
shouldFailRedemption: Bool = false, shouldFailRedemption: Bool = false,
redemptionError: AccountAPIError? = nil,
shouldFailAccountRefresh: Bool = false, shouldFailAccountRefresh: Bool = false,
shouldFailSignOut: Bool = false, shouldFailSignOut: Bool = false,
signOutDelayNanoseconds: UInt64 = 0, signOutDelayNanoseconds: UInt64 = 0,
@@ -836,6 +1163,7 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
centerSnapshot = snapshot centerSnapshot = snapshot
self.refreshedSnapshot = refreshedSnapshot self.refreshedSnapshot = refreshedSnapshot
self.shouldFailRedemption = shouldFailRedemption self.shouldFailRedemption = shouldFailRedemption
self.redemptionError = redemptionError
self.shouldFailAccountRefresh = shouldFailAccountRefresh self.shouldFailAccountRefresh = shouldFailAccountRefresh
self.shouldFailSignOut = shouldFailSignOut self.shouldFailSignOut = shouldFailSignOut
self.signOutDelayNanoseconds = signOutDelayNanoseconds self.signOutDelayNanoseconds = signOutDelayNanoseconds
@@ -925,6 +1253,9 @@ private actor AccountServiceSpy: AccountSessionServicing, AccountCenterServicing
if shouldFailRedemption { if shouldFailRedemption {
throw AccountServiceSpyError.failed throw AccountServiceSpyError.failed
} }
if let redemptionError {
throw redemptionError
}
redeemed.append(code) redeemed.append(code)
} }
+123
View File
@@ -0,0 +1,123 @@
// InvitePosterTests.swift
// OSGKeyboardTests
//
// The poster is only useful if a camera can read it back, so these tests
// decode the generated QR code instead of only checking that an image exists.
import CoreImage
@testable import OSGKeyboard
import OSGKeyboardShared
import XCTest
final class InvitePosterTests: XCTestCase {
private let inviteURL = URL(
string: "https://osglab.com/i/Abcdefghij_1234567890-?source=server"
)!
func testQRCodeDecodesToTheExactInvitationLink() throws {
let image = try XCTUnwrap(InviteQRCode.makeImage(for: inviteURL))
XCTAssertEqual(decodedMessages(in: image), [inviteURL.absoluteString])
}
@MainActor
func testPosterExportsAtFixedSizeAndStaysScannable() throws {
let poster = try XCTUnwrap(
InvitePosterRenderer.render(
inviteURL: inviteURL,
inviterName: nil,
language: .chinese,
colorScheme: .light
)
)
XCTAssertEqual(poster.width, 1080)
XCTAssertEqual(poster.height, 1440)
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
XCTAssertGreaterThan(cornerLuminance(of: poster), 0.95)
}
@MainActor
func testDarkPosterWithShortCodeAndLongNameStaysScannable() throws {
let poster = try XCTUnwrap(
InvitePosterRenderer.render(
inviteURL: inviteURL,
shortCode: "K7M2-Q9XA",
inviterName: "一位用户名比较长但仍然需要完整显示的朋友",
language: .chinese,
colorScheme: .dark
)
)
XCTAssertEqual(poster.width, 1080)
XCTAssertEqual(poster.height, 1440)
XCTAssertEqual(decodedMessages(in: poster), [inviteURL.absoluteString])
XCTAssertGreaterThan(
darkPixelFraction(
in: poster,
crop: CGRect(x: 300, y: 600, width: 480, height: 500)
),
0.15
)
// The graphite paper carries a subtle daylight wash, but remains
// distinctly dark rather than approaching the warm-paper surface.
XCTAssertLessThan(cornerLuminance(of: poster), 0.18)
}
private func decodedMessages(in image: CGImage) -> [String] {
let detector = CIDetector(
ofType: CIDetectorTypeQRCode,
context: nil,
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh]
)
return (detector?.features(in: CIImage(cgImage: image)) ?? [])
.compactMap { ($0 as? CIQRCodeFeature)?.messageString }
}
private func cornerLuminance(of image: CGImage) -> Double {
guard let corner = image.cropping(to: CGRect(x: 0, y: 0, width: 1, height: 1)) else {
return -1
}
var pixel = [UInt8](repeating: 0, count: 4)
let context = CGContext(
data: &pixel,
width: 1,
height: 1,
bitsPerComponent: 8,
bytesPerRow: 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
)
context?.draw(corner, in: CGRect(x: 0, y: 0, width: 1, height: 1))
return (0.2126 * Double(pixel[0])
+ 0.7152 * Double(pixel[1])
+ 0.0722 * Double(pixel[2])) / 255
}
private func darkPixelFraction(in image: CGImage, crop: CGRect) -> Double {
guard let cropped = image.cropping(to: crop) else { return 0 }
let width = 120
let height = 120
var pixels = [UInt8](repeating: 0, count: width * height * 4)
guard let context = CGContext(
data: &pixels,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else {
return 0
}
context.interpolationQuality = .none
context.draw(cropped, in: CGRect(x: 0, y: 0, width: width, height: height))
let darkCount = (0..<(width * height)).count { index in
let offset = index * 4
return Int(pixels[offset])
+ Int(pixels[offset + 1])
+ Int(pixels[offset + 2]) < 3 * 100
}
return Double(darkCount) / Double(width * height)
}
}
@@ -37,6 +37,59 @@ final class ReferralProfileTests: XCTestCase {
XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL) XCTAssertEqual(profile.code.inviteURL.absoluteString, inviteURL)
XCTAssertNil(profile.code.campaignID) XCTAssertNil(profile.code.campaignID)
XCTAssertNotNil(profile.binding) XCTAssertNotNil(profile.binding)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
}
func testReferralProfileDecodesShortCodeAndBindingDeadlineAndSurvivesCache() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "K7M2Q9XA"
},
"bindingDeadline": "2026-08-27T12:34:56Z"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
let cached = try JSONDecoder().decode(
ReferralProfile.self,
from: JSONEncoder().encode(profile)
)
XCTAssertEqual(profile.code.shortCode, "K7M2Q9XA")
XCTAssertEqual(profile.code.displayShortCode, "K7M2-Q9XA")
XCTAssertEqual(
profile.bindingDeadline,
ISO8601DateFormatter().date(from: "2026-08-27T12:34:56Z")
)
XCTAssertEqual(cached, profile)
}
func testMalformedShortCodeIsDroppedWithoutRejectingTheProfile() throws {
let data = Data(
"""
{
"code": {
"code": "Abcdefghij_1234567890-",
"inviteUrl": "https://osglab.com/i/Abcdefghij_1234567890-",
"createdAt": "2026-08-20T12:34:56Z",
"shortCode": "0OIL0OIL"
},
"bindingDeadline": "not a date"
}
""".utf8
)
let profile = try JSONDecoder().decode(ReferralProfile.self, from: data)
XCTAssertNil(profile.code.shortCode)
XCTAssertNil(profile.bindingDeadline)
} }
func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws { func testLiveReferralServiceUsesAuthenticatedGETWithBoundedTimeout() async throws {
@@ -0,0 +1,81 @@
// ScreenBrightnessBoostTests.swift
// OSGKeyboardTests
//
// Whatever order holds and scene changes arrive in, the user's own
// brightness must come back once nothing needs the boost.
@testable import OSGKeyboard
import XCTest
@MainActor
final class ScreenBrightnessBoostTests: XCTestCase {
private final class FakeScreen: ScreenBrightnessControlling {
var brightness: CGFloat
init(brightness: CGFloat) { self.brightness = brightness }
}
func testHoldRaisesBrightnessAndReleaseRestoresIt() {
let screen = FakeScreen(brightness: 0.4)
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.4)
}
func testOverlappingHoldsKeepTheOriginalLevel() {
let screen = FakeScreen(brightness: 0.3)
let boost = ScreenBrightnessBoost { screen }
// A new view appears before the previous one disappears.
boost.acquire()
boost.acquire()
boost.release()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.3)
}
func testBackgroundRestoresAndForegroundReapplies() {
let screen = FakeScreen(brightness: 0.5)
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
boost.setInForeground(false)
XCTAssertEqual(screen.brightness, 0.5)
boost.setInForeground(true)
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.5)
}
func testUnbalancedReleaseLeavesBrightnessAlone() {
let screen = FakeScreen(brightness: 0.6)
let boost = ScreenBrightnessBoost { screen }
boost.release()
boost.acquire()
XCTAssertEqual(screen.brightness, 1)
boost.release()
XCTAssertEqual(screen.brightness, 0.6)
}
func testMissingScreenIsRetriedOnTheNextChange() {
var screen: FakeScreen?
let boost = ScreenBrightnessBoost { screen }
boost.acquire()
screen = FakeScreen(brightness: 0.2)
boost.setInForeground(true)
XCTAssertEqual(screen?.brightness, 1)
boost.release()
XCTAssertEqual(screen?.brightness, 0.2)
}
}
+3 -1
View File
@@ -32,7 +32,9 @@
"OSGKeyboardTests/AccountSignInCoordinatorTests", "OSGKeyboardTests/AccountSignInCoordinatorTests",
"OSGKeyboardTests/AccountSnapshotLoaderTests", "OSGKeyboardTests/AccountSnapshotLoaderTests",
"OSGKeyboardTests/DeviceIntegrityTests", "OSGKeyboardTests/DeviceIntegrityTests",
"OSGKeyboardTests/ReferralProfileTests" "OSGKeyboardTests/ReferralProfileTests",
"OSGKeyboardTests/InvitePosterTests",
"OSGKeyboardTests/ScreenBrightnessBoostTests"
] ]
}, },
"managed_gateway": { "managed_gateway": {
+2
View File
@@ -188,6 +188,7 @@ targets:
NSContactsUsageDescription: "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number." NSContactsUsageDescription: "OSGKeyboard uses Contacts only when you choose Create Contact for a copied phone number."
NSRemindersFullAccessUsageDescription: "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text." NSRemindersFullAccessUsageDescription: "OSGKeyboard adds reminders only when you choose Extract To-dos for copied text."
NSCalendarsFullAccessUsageDescription: "OSGKeyboard adds calendar events only when you choose Extract Events for copied text." NSCalendarsFullAccessUsageDescription: "OSGKeyboard adds calendar events only when you choose Extract Events for copied text."
NSPhotoLibraryAddUsageDescription: "OSGKeyboard saves your invitation poster to Photos only when you choose Save Image."
UIBackgroundModes: UIBackgroundModes:
- audio - audio
BGTaskSchedulerPermittedIdentifiers: BGTaskSchedulerPermittedIdentifiers:
@@ -579,6 +580,7 @@ targets:
- path: OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift - path: OSGKeyboard/Views/Account/AccountCreditPurchaseManager.swift
- path: OSGKeyboard/Views/Account/LiveAccountServices.swift - path: OSGKeyboard/Views/Account/LiveAccountServices.swift
- path: OSGKeyboard/Views/Account/ReferralProfileViewModel.swift - path: OSGKeyboard/Views/Account/ReferralProfileViewModel.swift
- path: OSGKeyboard/Views/Account/InvitePoster.swift
- path: OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift - path: OSGKeyboard/Views/Account/AccountAppleAuthorizationFactories.swift
- path: OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift - path: OSGKeyboard/Services/AccountAnalyticsBearerBridge.swift
- path: OSGKeyboardHostSupport - path: OSGKeyboardHostSupport