Files
OSGKeyboard/OSGKeyboardShared/Features/Analytics/KeyboardUsageModels.swift
T
Rocky e5a83843db Expand onboarding and adaptive keyboard intelligence
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
2026-08-22 16:33:18 +08:00

425 lines
14 KiB
Swift

// KeyboardUsageModels.swift
// OSGKeyboard · Shared
//
// Fixed-schema keyboard usage summaries. Raw text never crosses this model
// boundary: callers provide only grapheme counts and random session IDs.
import Foundation
public enum KeyboardTextInsertionSource: String, CaseIterable, Sendable {
case manualKeyboard = "MANUAL_KEYBOARD"
case voiceTranscription = "VOICE_TRANSCRIPTION"
case aiGenerated = "AI_GENERATED"
case pasteboard = "PASTEBOARD"
case editGenerated = "EDIT_GENERATED"
case redo = "REDO"
case assistantAction = "ASSISTANT_ACTION"
case debugDemo = "DEBUG_DEMO"
public var contributesToKeyboardUsage: Bool {
self == .manualKeyboard
}
}
public struct KeyboardUsageCharacterCounts: Equatable, Sendable {
public static let maximumPerCategory = 1_000_000
public let chinese: Int
public let english: Int
public let other: Int
public init(chinese: Int = 0, english: Int = 0, other: Int = 0) {
self.chinese = Self.clamped(chinese)
self.english = Self.clamped(english)
self.other = Self.clamped(other)
}
public var total: Int {
chinese + english + other
}
public var isEmpty: Bool {
total == 0
}
private static func clamped(_ value: Int) -> Int {
min(maximumPerCategory, max(0, value))
}
}
public enum KeyboardUsageCharacterClassifier {
public static func classify(_ text: String) -> KeyboardUsageCharacterCounts {
var chinese = 0
var english = 0
var other = 0
for character in text {
switch classification(of: character) {
case .chinese:
chinese = saturatingIncrement(chinese)
case .english:
english = saturatingIncrement(english)
case .other:
other = saturatingIncrement(other)
}
}
return KeyboardUsageCharacterCounts(
chinese: chinese,
english: english,
other: other
)
}
private enum Classification {
case chinese
case english
case other
}
private static func classification(of character: Character) -> Classification {
// ICU Script properties cover CJK extensions and future Unicode updates;
// checking the whole grapheme keeps combining sequences at one count.
if character.unicodeScalars.contains(where: {
matchesScript($0, regularExpression: hanScriptExpression)
}) {
return .chinese
}
if character.unicodeScalars.contains(where: {
isLetter($0) && matchesScript($0, regularExpression: latinScriptExpression)
}) {
return .english
}
return .other
}
private static func matchesScript(
_ scalar: Unicode.Scalar,
regularExpression: NSRegularExpression
) -> Bool {
let text = String(scalar)
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return regularExpression.firstMatch(
in: text,
options: [],
range: range
) != nil
}
private static func isLetter(_ scalar: Unicode.Scalar) -> Bool {
switch scalar.properties.generalCategory {
case .uppercaseLetter,
.lowercaseLetter,
.titlecaseLetter,
.modifierLetter,
.otherLetter:
return true
default:
return false
}
}
private static func saturatingIncrement(_ value: Int) -> Int {
min(KeyboardUsageCharacterCounts.maximumPerCategory, value + 1)
}
private static let hanScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Han}$"#
)
private static let latinScriptExpression = try! NSRegularExpression(
pattern: #"^\p{sc=Latin}$"#
)
}
public enum KeyboardUsageSessionClassification: Int, CaseIterable, Sendable {
case chineseOnly = 0
case englishOnly = 1
case mixedLanguage = 2
case otherOnly = 3
init(counts: KeyboardUsageCharacterCounts) {
switch (counts.chinese > 0, counts.english > 0) {
case (true, true):
self = .mixedLanguage
case (true, false):
self = .chineseOnly
case (false, true):
self = .englishOnly
case (false, false):
self = .otherOnly
}
}
func merging(_ counts: KeyboardUsageCharacterCounts) -> Self {
let hasChinese = self == .chineseOnly
|| self == .mixedLanguage
|| counts.chinese > 0
let hasEnglish = self == .englishOnly
|| self == .mixedLanguage
|| counts.english > 0
switch (hasChinese, hasEnglish) {
case (true, true):
return .mixedLanguage
case (true, false):
return .chineseOnly
case (false, true):
return .englishOnly
case (false, false):
return .otherOnly
}
}
}
public enum KeyboardUsageModelError: Error, Sendable {
case invalidDate
case invalidCount
case invalidSessionPartition
case invalidVersion
case invalidResponseCounts
case unknownField(String)
}
public struct KeyboardUsageSummary: Codable, Equatable, Sendable {
public let clientSummaryId: UUID
public let summaryDate: String
public let chineseCharacterCount: Int
public let englishCharacterCount: Int
public let otherCharacterCount: Int
public let inputSessionCount: Int
public let chineseOnlySessionCount: Int
public let englishOnlySessionCount: Int
public let mixedLanguageSessionCount: Int
public let otherOnlySessionCount: Int
public let appVersion: String
public let osVersion: String
public init(
clientSummaryId: UUID,
summaryDate: String,
chineseCharacterCount: Int,
englishCharacterCount: Int,
otherCharacterCount: Int,
inputSessionCount: Int,
chineseOnlySessionCount: Int,
englishOnlySessionCount: Int,
mixedLanguageSessionCount: Int,
otherOnlySessionCount: Int,
appVersion: String,
osVersion: String
) throws {
let characterCounts = [
chineseCharacterCount,
englishCharacterCount,
otherCharacterCount
]
let sessionCounts = [
inputSessionCount,
chineseOnlySessionCount,
englishOnlySessionCount,
mixedLanguageSessionCount,
otherOnlySessionCount
]
guard KeyboardUsageUTCDate.date(from: summaryDate) != nil else {
throw KeyboardUsageModelError.invalidDate
}
guard characterCounts.allSatisfy({
(0...KeyboardUsageCharacterCounts.maximumPerCategory).contains($0)
}),
sessionCounts.allSatisfy({ (0...100_000).contains($0) }) else {
throw KeyboardUsageModelError.invalidCount
}
guard chineseOnlySessionCount
+ englishOnlySessionCount
+ mixedLanguageSessionCount
+ otherOnlySessionCount == inputSessionCount,
characterCounts.reduce(0, +) >= inputSessionCount else {
throw KeyboardUsageModelError.invalidSessionPartition
}
guard AnalyticsEnvironment.isSafeVersion(appVersion),
AnalyticsEnvironment.isSafeVersion(osVersion) else {
throw KeyboardUsageModelError.invalidVersion
}
self.clientSummaryId = clientSummaryId
self.summaryDate = summaryDate
self.chineseCharacterCount = chineseCharacterCount
self.englishCharacterCount = englishCharacterCount
self.otherCharacterCount = otherCharacterCount
self.inputSessionCount = inputSessionCount
self.chineseOnlySessionCount = chineseOnlySessionCount
self.englishOnlySessionCount = englishOnlySessionCount
self.mixedLanguageSessionCount = mixedLanguageSessionCount
self.otherOnlySessionCount = otherOnlySessionCount
self.appVersion = appVersion
self.osVersion = osVersion
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case clientSummaryId
case summaryDate
case chineseCharacterCount
case englishCharacterCount
case otherCharacterCount
case inputSessionCount
case chineseOnlySessionCount
case englishOnlySessionCount
case mixedLanguageSessionCount
case otherOnlySessionCount
case appVersion
case osVersion
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
clientSummaryId: container.decode(UUID.self, forKey: .clientSummaryId),
summaryDate: container.decode(String.self, forKey: .summaryDate),
chineseCharacterCount: container.decode(Int.self, forKey: .chineseCharacterCount),
englishCharacterCount: container.decode(Int.self, forKey: .englishCharacterCount),
otherCharacterCount: container.decode(Int.self, forKey: .otherCharacterCount),
inputSessionCount: container.decode(Int.self, forKey: .inputSessionCount),
chineseOnlySessionCount: container.decode(
Int.self,
forKey: .chineseOnlySessionCount
),
englishOnlySessionCount: container.decode(
Int.self,
forKey: .englishOnlySessionCount
),
mixedLanguageSessionCount: container.decode(
Int.self,
forKey: .mixedLanguageSessionCount
),
otherOnlySessionCount: container.decode(
Int.self,
forKey: .otherOnlySessionCount
),
appVersion: container.decode(String.self, forKey: .appVersion),
osVersion: container.decode(String.self, forKey: .osVersion)
)
}
}
public struct KeyboardUsageUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let summaries: [KeyboardUsageSummary]
public init(installationId: UUID, summaries: [KeyboardUsageSummary]) {
self.installationId = installationId
self.summaries = summaries
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case summaries
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
summaries = try container.decode([KeyboardUsageSummary].self, forKey: .summaries)
}
}
public struct KeyboardUsageUploadResponse: Codable, Equatable, Sendable {
public let accepted: Int
public let replayed: Int
public init(accepted: Int, replayed: Int) throws {
guard accepted >= 0, replayed >= 0 else {
throw KeyboardUsageModelError.invalidResponseCounts
}
self.accepted = accepted
self.replayed = replayed
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case accepted
case replayed
}
public init(from decoder: Decoder) throws {
try KeyboardUsageCodableAllowlist.rejectUnknownKeys(
in: decoder,
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
accepted: container.decode(Int.self, forKey: .accepted),
replayed: container.decode(Int.self, forKey: .replayed)
)
}
}
enum KeyboardUsageUTCDate {
static func string(from date: Date) -> String {
formatter.string(from: date)
}
static func date(from value: String) -> Date? {
guard value.utf8.count == 10 else { return nil }
return formatter.date(from: value)
}
static func oldestAcceptedDate(relativeTo today: String) -> String? {
guard let todayDate = date(from: today),
let oldest = calendar.date(byAdding: .day, value: -35, to: todayDate) else {
return nil
}
return string(from: oldest)
}
private static var formatter: DateFormatter {
let formatter = DateFormatter()
formatter.calendar = calendar
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "yyyy-MM-dd"
formatter.isLenient = false
return formatter
}
private static var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.locale = Locale(identifier: "en_US_POSIX")
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
return calendar
}
}
private enum KeyboardUsageCodableAllowlist {
static func rejectUnknownKeys(
in decoder: Decoder,
allowed: Set<String>
) throws {
let container = try decoder.container(keyedBy: KeyboardUsageAnyCodingKey.self)
if let unknown = container.allKeys.first(where: {
!allowed.contains($0.stringValue)
}) {
throw KeyboardUsageModelError.unknownField(unknown.stringValue)
}
}
}
private struct KeyboardUsageAnyCodingKey: CodingKey {
let stringValue: String
let intValue: Int?
init?(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}