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.
This commit is contained in:
Rocky
2026-08-22 16:33:18 +08:00
parent ac374631ae
commit e5a83843db
162 changed files with 23574 additions and 1013 deletions
@@ -89,7 +89,6 @@ public enum AnalyticsModelError: Error, Sendable {
}
public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let installationId: UUID
public let clientEventId: UUID
public let eventType: AnalyticsEventType
public let occurredAt: Date
@@ -103,7 +102,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
public let durationBucket: AnalyticsDurationBucket?
public init(
installationId: UUID,
clientEventId: UUID,
eventType: AnalyticsEventType,
occurredAt: Date,
@@ -121,7 +119,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidVersion
}
self.installationId = installationId
self.clientEventId = clientEventId
self.eventType = eventType
self.occurredAt = occurredAt
@@ -140,7 +137,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case clientEventId
case eventType
case occurredAt
@@ -160,7 +156,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
let installationId = try container.decode(UUID.self, forKey: .installationId)
let clientEventId = try container.decode(UUID.self, forKey: .clientEventId)
let eventType = try container.decode(AnalyticsEventType.self, forKey: .eventType)
let occurredAtText = try container.decode(String.self, forKey: .occurredAt)
@@ -172,7 +167,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
)
}
try self.init(
installationId: installationId,
clientEventId: clientEventId,
eventType: eventType,
occurredAt: occurredAt,
@@ -204,7 +198,6 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
throw AnalyticsModelError.invalidDimensions(eventType)
}
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(installationId, forKey: .installationId)
try container.encode(clientEventId, forKey: .clientEventId)
try container.encode(eventType, forKey: .eventType)
try container.encode(AnalyticsWireDate.string(from: occurredAt), forKey: .occurredAt)
@@ -237,13 +230,16 @@ public struct AnalyticsEvent: Codable, Equatable, Sendable {
}
public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
public let installationId: UUID
public let events: [AnalyticsEvent]
public init(events: [AnalyticsEvent]) {
public init(installationId: UUID, events: [AnalyticsEvent]) {
self.installationId = installationId
self.events = events
}
private enum CodingKeys: String, CodingKey, CaseIterable {
case installationId
case events
}
@@ -253,6 +249,7 @@ public struct AnalyticsUploadRequest: Codable, Equatable, Sendable {
allowed: Set(CodingKeys.allCases.map(\.rawValue))
)
let container = try decoder.container(keyedBy: CodingKeys.self)
installationId = try container.decode(UUID.self, forKey: .installationId)
events = try container.decode([AnalyticsEvent].self, forKey: .events)
}
}
@@ -335,6 +332,7 @@ private extension AnalyticsEventType {
switch self {
case .firstOpen, .inviteOpened:
return [
.none,
DimensionSet(
acquisitionChannel: true,
feature: false,
@@ -72,6 +72,7 @@ struct AnalyticsLeasedEvent: Sendable {
struct AnalyticsLeasedBatch: Sendable {
let leaseID: String
let installationID: UUID
let events: [AnalyticsLeasedEvent]
let body: Data
}
@@ -141,7 +142,6 @@ public actor AnalyticsRepository {
return
}
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .firstOpen,
occurredAt: clock.now(),
@@ -150,7 +150,11 @@ public actor AnalyticsRepository {
osVersion: context.environment.osVersion,
acquisitionChannel: firstOpenAcquisitionChannel
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
"1",
for: MetadataKey.firstOpenRecorded,
@@ -172,6 +176,21 @@ public actor AnalyticsRepository {
}
}
/// Returns the existing App Group installation UUID, creating it only while
/// analytics is enabled. Aggregate keyboard usage reuses this identity.
public func installationIdentifierIfEnabled() -> UUID? {
guard let database = openDatabaseIfNeeded() else { return nil }
do {
return try database.immediateTransaction {
_ = try ensureEnabled(database)
guard try isEnabled(database) else { return nil }
return try ensureInstallation(database)
}
} catch {
return nil
}
}
/// Read-only diagnostics for tests and support tooling. Event payload content
/// is deliberately excluded from this public snapshot.
public func debugSnapshot() -> AnalyticsRepositoryDebugSnapshot {
@@ -355,7 +374,6 @@ public actor AnalyticsRepository {
guard try isEnabled(database) else { return }
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: eventType,
occurredAt: clock.now(),
@@ -368,7 +386,11 @@ public actor AnalyticsRepository {
failureCategory: dimensions.failureCategory,
durationBucket: dimensions.durationBucket
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try enforceStoragePolicy(database)
}
}
@@ -402,7 +424,6 @@ public actor AnalyticsRepository {
let installationID = try ensureInstallation(database)
let event = try AnalyticsEvent(
installationId: installationID,
clientEventId: uuidGenerator.makeUUID(),
eventType: .sessionStarted,
occurredAt: now,
@@ -410,7 +431,11 @@ public actor AnalyticsRepository {
appVersion: context.environment.appVersion,
osVersion: context.environment.osVersion
)
try insert(event, database: database)
try insert(
event,
installationID: installationID,
database: database
)
try setMetadata(
String(now.timeIntervalSince1970),
for: key,
@@ -441,19 +466,42 @@ public actor AnalyticsRepository {
try releaseExpiredEventLeases(now: now, database: database)
try deleteExpiredEvents(now: now, database: database)
guard let installationText = try database.query(
"""
SELECT installation_id
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT 1
""",
bindings: [.double(now)]
).first?.text(at: 0),
let installationID = UUID(uuidString: installationText) else {
try clearUploadLease(database)
return nil
}
let candidates = try database.query(
"""
SELECT id, payload, attempt_count
FROM pending_events
WHERE next_attempt_at <= ? AND lease_id IS NULL
WHERE installation_id = ?
AND next_attempt_at <= ?
AND lease_id IS NULL
ORDER BY priority DESC, created_at ASC
LIMIT ?
""",
bindings: [.double(now), .int64(Int64(upload.maximumBatchCount))]
bindings: [
.text(installationText),
.double(now),
.int64(Int64(upload.maximumBatchCount))
]
)
var selected: [AnalyticsLeasedEvent] = []
var bodySize = Self.emptyRequestBody.count
var bodySize = Self.emptyRequestBody(
installationID: installationID
).count
for row in candidates {
guard let rowID = row.int64(at: 0),
let payload = row.data(at: 1),
@@ -504,8 +552,12 @@ public actor AnalyticsRepository {
}
return AnalyticsLeasedBatch(
leaseID: leaseID,
installationID: installationID,
events: selected,
body: Self.requestBody(for: selected)
body: Self.requestBody(
for: selected,
installationID: installationID
)
)
}
} catch {
@@ -696,18 +748,20 @@ public actor AnalyticsRepository {
private func insert(
_ event: AnalyticsEvent,
installationID: UUID,
database: SQLiteDatabase
) throws {
let payload = try AnalyticsCanonicalJSON.encode(event)
try database.execute(
"""
INSERT OR IGNORE INTO pending_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, priority, lease_id, lease_expires_at,
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, priority, lease_id, lease_expires_at,
attempt_count, next_attempt_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, 0, 0, ?)
""",
bindings: [
.text(installationID.uuidString.lowercased()),
.text(event.clientEventId.uuidString.lowercased()),
.text(event.eventType.rawValue),
.double(event.occurredAt.timeIntervalSince1970),
@@ -887,11 +941,12 @@ public actor AnalyticsRepository {
try database.execute(
"""
INSERT INTO quarantined_events (
client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, reason, quarantined_at
installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, reason,
quarantined_at
)
SELECT client_event_id, event_type, occurred_at, surface, payload,
payload_size, attempt_count, ?, ?
SELECT installation_id, client_event_id, event_type, occurred_at,
surface, payload, payload_size, attempt_count, ?, ?
FROM pending_events
WHERE id = ?\(leaseClause)
""",
@@ -907,10 +962,18 @@ public actor AnalyticsRepository {
try enforceStoragePolicy(database)
}
private static let emptyRequestBody = Data(#"{"events":[]}"#.utf8)
private static func emptyRequestBody(installationID: UUID) -> Data {
requestBody(for: [], installationID: installationID)
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -967,7 +1030,7 @@ public actor AnalyticsRepository {
private func migrate(_ database: SQLiteDatabase) throws {
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
guard version <= 1 else {
guard version <= 2 else {
throw SQLiteStoreError(
code: SQLITE_MISMATCH,
category: .schema,
@@ -984,58 +1047,90 @@ public actor AnalyticsRepository {
) WITHOUT ROWID
"""
)
try createCurrentQueueSchema(database)
try database.execute("PRAGMA user_version = 2")
}
} else if version == 1 {
try database.immediateTransaction {
// Another process may have completed the migration while this
// connection waited for BEGIN IMMEDIATE.
guard try database.scalarInt64("PRAGMA user_version") == 1 else {
return
}
// v1 payloads contain installationId inside each event and are
// incompatible with the server's strict v2 envelope. Drop them
// without decoding so neither old content nor identifiers can
// escape through logs or a partially migrated retry.
try database.execute("DROP TABLE IF EXISTS pending_events")
try database.execute("DROP TABLE IF EXISTS quarantined_events")
try createCurrentQueueSchema(database)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
"DELETE FROM metadata WHERE key IN (?, ?, ?)",
bindings: [
.text(MetadataKey.firstOpenRecorded),
.text(MetadataKey.uploadLeaseOwner),
.text(MetadataKey.uploadLeaseExpiresAt)
]
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(next_attempt_at, lease_id, priority, created_at)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
try database.execute("PRAGMA user_version = 1")
try database.execute("PRAGMA user_version = 2")
}
}
}
private func createCurrentQueueSchema(_ database: SQLiteDatabase) throws {
try database.execute(
"""
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL CHECK(payload_size >= 0),
priority INTEGER NOT NULL,
lease_id TEXT,
lease_expires_at REAL,
attempt_count INTEGER NOT NULL DEFAULT 0,
next_attempt_at REAL NOT NULL DEFAULT 0,
created_at REAL NOT NULL
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_ready
ON pending_events(
installation_id, next_attempt_at, lease_id, priority, created_at
)
"""
)
try database.execute(
"""
CREATE INDEX IF NOT EXISTS pending_events_expiry
ON pending_events(occurred_at)
"""
)
try database.execute(
"""
CREATE TABLE IF NOT EXISTS quarantined_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
installation_id TEXT NOT NULL,
client_event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
occurred_at REAL NOT NULL,
surface TEXT NOT NULL,
payload BLOB NOT NULL,
payload_size INTEGER NOT NULL,
attempt_count INTEGER NOT NULL,
reason TEXT NOT NULL,
quarantined_at REAL NOT NULL
)
"""
)
}
private func applyFileProtection(to databaseURL: URL) {
#if os(iOS)
for url in [
@@ -1161,7 +1256,7 @@ private struct SQLiteStoreError: Error, Sendable {
let operation: String
}
private enum SQLiteBinding {
enum SQLiteBinding {
case null
case int64(Int64)
case double(Double)
@@ -1169,7 +1264,7 @@ private enum SQLiteBinding {
case blob(Data)
}
private struct SQLiteRow {
struct SQLiteRow {
private let values: [SQLiteValue]
init(statement: OpaquePointer) {
@@ -1243,7 +1338,7 @@ private enum SQLiteValue {
case blob(Data)
}
private final class SQLiteDatabase {
final class SQLiteDatabase {
private static let transient = unsafeBitCast(
-1,
to: sqlite3_destructor_type.self
@@ -77,7 +77,7 @@ public actor AnalyticsUploadCoordinator {
/// Performs a bounded drain. The default keeps extension execution time and
/// memory predictable while allowing a host app to request more batches.
public func uploadAvailableEvents(maximumBatches: Int = 1) async {
guard !uploadInProgress else { return }
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
@@ -95,18 +95,35 @@ public actor AnalyticsUploadCoordinator {
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
let batchLimit = max(1, maximumBatches)
for _ in 0..<batchLimit {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
events: batch.events,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
events: batch.events,
leaseID: batch.leaseID,
@@ -120,21 +137,28 @@ public actor AnalyticsUploadCoordinator {
await process(
events: batch.events,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !events.isEmpty else { return }
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
guard await renewLease(
events: events,
leaseID: leaseID,
@@ -142,11 +166,23 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(events: events, token: authorization.token)
response = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -172,15 +208,24 @@ public actor AnalyticsUploadCoordinator {
) else {
return
}
let refreshed = try await send(events: events, token: authorization.token)
let refreshed = try await send(
events: events,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
await scheduleRetry(
events: events,
leaseID: leaseID,
@@ -195,6 +240,7 @@ public actor AnalyticsUploadCoordinator {
await processResponse(
response,
events: events,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -204,10 +250,15 @@ public actor AnalyticsUploadCoordinator {
private func processResponse(
_ response: AnalyticsHTTPResponse,
events: [AnalyticsLeasedEvent],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.releaseEvents(events, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: AnalyticsUploadResponse
@@ -270,12 +321,14 @@ public actor AnalyticsUploadCoordinator {
let midpoint = events.count / 2
await process(
events: Array(events[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
events: Array(events[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
@@ -340,8 +393,18 @@ public actor AnalyticsUploadCoordinator {
}
}
private func releaseForCancellation(
events: [AnalyticsLeasedEvent],
leaseID: String,
ownerID: String
) async {
await repository.releaseEvents(events, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
events: [AnalyticsLeasedEvent],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
@@ -355,7 +418,10 @@ public actor AnalyticsUploadCoordinator {
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(for: events)
body: Self.requestBody(
for: events,
installationID: installationID
)
)
)
}
@@ -445,8 +511,14 @@ public actor AnalyticsUploadCoordinator {
events.map(\.attemptCount).max() ?? 0
}
private static func requestBody(for events: [AnalyticsLeasedEvent]) -> Data {
var body = Data(#"{"events":["#.utf8)
private static func requestBody(
for events: [AnalyticsLeasedEvent],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","events":["#
.utf8
)
for index in events.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
@@ -0,0 +1,129 @@
// KeyboardUsageClient.swift
// OSGKeyboard · Shared
//
// Fire-and-forget numeric recording facade. Calls are serialized per process so
// a UTC rollover cannot finalize a day ahead of an earlier queued insertion.
import Foundation
public protocol KeyboardUsageRecording: Sendable {
func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
)
}
public struct NoopKeyboardUsageRecorder: KeyboardUsageRecording {
public init() {}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {}
}
public final class LiveKeyboardUsageRecorder:
KeyboardUsageRecording,
@unchecked Sendable {
private let analyticsRepository: AnalyticsRepository
private let repository: KeyboardUsageRepository
private let environment: AnalyticsEnvironment
private let clock: any AnalyticsWallClock
private let trigger: any AnalyticsUploadTriggering
private let lock = NSLock()
private var tailTask: Task<Void, Never>?
public init(
analyticsRepository: AnalyticsRepository,
repository: KeyboardUsageRepository,
environment: AnalyticsEnvironment,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger()
) {
self.analyticsRepository = analyticsRepository
self.repository = repository
self.environment = environment
self.clock = clock
self.trigger = trigger
}
public func recordManualKeyboardCounts(
_ counts: KeyboardUsageCharacterCounts,
sessionID: UUID
) {
guard !counts.isEmpty else { return }
let occurredAt = clock.now()
let analyticsRepository = analyticsRepository
let repository = repository
let environment = environment
let trigger = trigger
lock.withLock {
let previous = tailTask
tailTask = Task {
await previous?.value
guard let installationID =
await analyticsRepository.installationIdentifierIfEnabled() else {
return
}
await repository.record(
counts: counts,
sessionID: sessionID,
installationID: installationID,
occurredAt: occurredAt,
environment: environment
)
await trigger.requestUpload()
}
}
}
func waitForPendingRecords() async {
let task = lock.withLock { tailTask }
await task?.value
}
}
public struct KeyboardUsageRuntime: Sendable {
public let repository: KeyboardUsageRepository
public let recorder: any KeyboardUsageRecording
public let uploadCoordinator: KeyboardUsageUploadCoordinator
public init(
environment: AnalyticsEnvironment,
analyticsRepository: AnalyticsRepository,
repositoryConfiguration: KeyboardUsageRepositoryConfiguration = .appGroupDefault(),
uploadConfiguration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
wallClock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
trigger: any AnalyticsUploadTriggering = NoopAnalyticsUploadTrigger(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
let repository = KeyboardUsageRepository(
configuration: repositoryConfiguration,
clock: wallClock,
uuidGenerator: uuidGenerator
)
self.repository = repository
recorder = LiveKeyboardUsageRecorder(
analyticsRepository: analyticsRepository,
repository: repository,
environment: environment,
clock: wallClock,
trigger: trigger
)
uploadCoordinator = KeyboardUsageUploadCoordinator(
repository: repository,
configuration: uploadConfiguration,
network: network,
bearerProvider: bearerProvider,
clock: wallClock,
uuidGenerator: uuidGenerator,
random: random,
logger: logger
)
}
}
@@ -0,0 +1,424 @@
// 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
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,547 @@
// KeyboardUsageUploadCoordinator.swift
// OSGKeyboard · Shared
//
// Uploads immutable daily summaries with the existing analytics transport,
// optional account bearer, bounded retry and cross-process leases.
import Foundation
public struct KeyboardUsageUploadConfiguration: Sendable {
public let endpoint: URL
public let maximumBatchCount: Int
public let globalLeaseDuration: TimeInterval
public let summaryLeaseDuration: TimeInterval
public let maximumBackoff: TimeInterval
public init(
endpoint: URL,
maximumBatchCount: Int = 50,
globalLeaseDuration: TimeInterval = 2 * 60,
summaryLeaseDuration: TimeInterval = 5 * 60,
maximumBackoff: TimeInterval = 6 * 60 * 60
) {
self.endpoint = endpoint
self.maximumBatchCount = min(50, max(1, maximumBatchCount))
self.globalLeaseDuration = max(60, globalLeaseDuration)
self.summaryLeaseDuration = max(60, summaryLeaseDuration)
self.maximumBackoff = min(6 * 60 * 60, max(60, maximumBackoff))
}
}
public actor KeyboardUsageUploadCoordinator {
private struct AuthorizationState {
var token: String?
var didRefresh = false
}
private let repository: KeyboardUsageRepository
private let configuration: KeyboardUsageUploadConfiguration
private let network: any AnalyticsNetworking
private let bearerProvider: (any AnalyticsBearerProviding)?
private let clock: any AnalyticsWallClock
private let uuidGenerator: any AnalyticsUUIDGenerating
private let random: any AnalyticsRandomGenerating
private let logger: any AnalyticsLogging
private var uploadInProgress = false
public init(
repository: KeyboardUsageRepository,
configuration: KeyboardUsageUploadConfiguration,
network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(),
bearerProvider: (any AnalyticsBearerProviding)? = nil,
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator(),
random: any AnalyticsRandomGenerating = SystemAnalyticsRandomGenerator(),
logger: any AnalyticsLogging = NoopAnalyticsLogger()
) {
self.repository = repository
self.configuration = configuration
self.network = network
self.bearerProvider = bearerProvider
self.clock = clock
self.uuidGenerator = uuidGenerator
self.random = random
self.logger = logger
}
public func uploadAvailableSummaries(maximumBatches: Int = 1) async {
guard !uploadInProgress, !Task.isCancelled else { return }
uploadInProgress = true
defer { uploadInProgress = false }
guard configuration.endpoint.scheme?.lowercased() == "https" else {
log(
outcome: .skipped,
count: 0,
category: .client
)
return
}
let ownerID = uuidGenerator.makeUUID().uuidString.lowercased()
for _ in 0..<max(1, maximumBatches) {
guard !Task.isCancelled else { return }
guard let batch = await repository.leaseBatch(
ownerID: ownerID,
configuration: configuration
) else {
return
}
guard !Task.isCancelled else {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
var authorization = AuthorizationState()
if let bearerProvider {
do {
authorization.token = try await bearerProvider.bearerToken()
} catch {
if Task.isCancelled {
await releaseForCancellation(
summaries: batch.summaries,
leaseID: batch.leaseID,
ownerID: ownerID
)
return
}
await scheduleRetry(
batch.summaries,
leaseID: batch.leaseID,
response: nil,
category: .authentication
)
await repository.releaseGlobalLease(ownerID: ownerID)
return
}
}
await process(
batch.summaries,
installationID: batch.installationID,
leaseID: batch.leaseID,
ownerID: ownerID,
authorization: &authorization
)
await repository.releaseGlobalLease(ownerID: ownerID)
guard !Task.isCancelled else { return }
}
}
private func process(
_ summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !summaries.isEmpty else { return }
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
let response: AnalyticsHTTPResponse
do {
response = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: nil,
category: Self.networkCategory(for: error)
)
return
}
if response.statusCode == 401,
!authorization.didRefresh,
let bearerProvider {
authorization.didRefresh = true
let failedToken = authorization.token
do {
authorization.token = try await bearerProvider.refreshBearerToken(
afterUnauthorizedAccessToken: failedToken
)
guard await renewLease(
summaries: summaries,
leaseID: leaseID,
ownerID: ownerID
) else {
return
}
let refreshed = try await send(
summaries: summaries,
installationID: installationID,
token: authorization.token
)
await processResponse(
refreshed,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
} catch {
if Task.isCancelled {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
}
return
}
await processResponse(
response,
summaries: summaries,
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
}
private func processResponse(
_ response: AnalyticsHTTPResponse,
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
leaseID: String,
ownerID: String,
authorization: inout AuthorizationState
) async {
guard !Task.isCancelled else {
await repository.release(summaries: summaries, leaseID: leaseID)
return
}
switch response.statusCode {
case 200:
let decoded: KeyboardUsageUploadResponse
do {
decoded = try JSONDecoder().decode(
KeyboardUsageUploadResponse.self,
from: response.body
)
} catch {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .decoding
)
return
}
guard decoded.accepted + decoded.replayed == summaries.count else {
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .countMismatch
)
return
}
let completed = await repository.complete(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID
)
log(
outcome: completed ? .uploaded : .retryScheduled,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: completed ? nil : .storage
)
case 400, 409, 422:
if summaries.count == 1 {
await repository.quarantine(
rowIDs: [summaries[0].rowID],
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: 1,
statusCode: response.statusCode,
attempt: summaries[0].attemptCount,
category: .client
)
return
}
let midpoint = summaries.count / 2
await process(
Array(summaries[..<midpoint]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
await process(
Array(summaries[midpoint...]),
installationID: installationID,
leaseID: leaseID,
ownerID: ownerID,
authorization: &authorization
)
case 401:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .authentication,
minimumDelay: configuration.maximumBackoff
)
case 408:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .timeout
)
case 429:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .rateLimited
)
case 500...599:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
case 400...499:
await repository.quarantine(
rowIDs: summaries.map(\.rowID),
leaseID: leaseID,
statusCode: response.statusCode
)
log(
outcome: .quarantined,
count: summaries.count,
statusCode: response.statusCode,
attempt: maximumAttempt(in: summaries),
category: .client
)
default:
await scheduleRetry(
summaries,
leaseID: leaseID,
response: response,
category: .server
)
}
}
private func releaseForCancellation(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async {
await repository.release(summaries: summaries, leaseID: leaseID)
await repository.releaseGlobalLease(ownerID: ownerID)
}
private func send(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID,
token: String?
) async throws -> AnalyticsHTTPResponse {
var headers = [
"Accept": "application/json",
"Content-Type": "application/json"
]
if let token, !token.isEmpty {
headers["Authorization"] = "Bearer \(token)"
}
return try await network.send(
AnalyticsHTTPRequest(
url: configuration.endpoint,
headers: headers,
body: Self.requestBody(
summaries: summaries,
installationID: installationID
)
)
)
}
private func renewLease(
summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
ownerID: String
) async -> Bool {
let renewed = await repository.renewLease(
ownerID: ownerID,
leaseID: leaseID,
configuration: configuration
)
guard renewed else {
await repository.release(summaries: summaries, leaseID: leaseID)
log(
outcome: .skipped,
count: summaries.count,
attempt: maximumAttempt(in: summaries),
category: .storage
)
return false
}
return true
}
private func scheduleRetry(
_ summaries: [KeyboardUsageLeasedSummary],
leaseID: String,
response: AnalyticsHTTPResponse?,
category: AnalyticsUploadErrorCategory,
minimumDelay: TimeInterval = 0
) async {
let attempt = maximumAttempt(in: summaries) + 1
let exponent = min(attempt - 1, 16)
let ceiling = min(
configuration.maximumBackoff,
pow(2, Double(exponent))
)
let jitterMilliseconds = random.next(
upperBound: UInt64(max(0, ceiling * 1_000))
)
let jitter = TimeInterval(jitterMilliseconds) / 1_000
let retryAfter = response.flatMap(retryAfterDelay) ?? 0
let delay = min(
configuration.maximumBackoff,
max(minimumDelay, retryAfter, jitter)
)
await repository.scheduleRetry(
summaries: summaries,
leaseID: leaseID,
delay: delay
)
log(
outcome: .retryScheduled,
count: summaries.count,
statusCode: response?.statusCode,
attempt: attempt,
category: category
)
}
private func retryAfterDelay(
_ response: AnalyticsHTTPResponse
) -> TimeInterval? {
guard let value = response.header(named: "Retry-After")?
.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil
}
if let seconds = TimeInterval(value) {
return min(configuration.maximumBackoff, max(0, seconds))
}
guard let date = Self.httpDateFormatter.date(from: value) else {
return nil
}
return min(
configuration.maximumBackoff,
max(0, date.timeIntervalSince(clock.now()))
)
}
private func maximumAttempt(
in summaries: [KeyboardUsageLeasedSummary]
) -> Int {
summaries.map(\.attemptCount).max() ?? 0
}
private func log(
outcome: AnalyticsUploadLogEntry.Outcome,
count: Int,
statusCode: Int? = nil,
attempt: Int = 0,
category: AnalyticsUploadErrorCategory?
) {
logger.log(
AnalyticsUploadLogEntry(
outcome: outcome,
eventCount: count,
statusCode: statusCode,
attempt: attempt,
errorCategory: category
)
)
}
private static func requestBody(
summaries: [KeyboardUsageLeasedSummary],
installationID: UUID
) -> Data {
var body = Data(
#"{"installationId":"\#(installationID.uuidString.lowercased())","summaries":["#
.utf8
)
for index in summaries.indices {
if index > 0 {
body.append(UInt8(ascii: ","))
}
body.append(summaries[index].payload)
}
body.append(Data("]}".utf8))
return body
}
private static func networkCategory(
for error: Error
) -> AnalyticsUploadErrorCategory {
guard let urlError = error as? URLError else { return .network }
switch urlError.code {
case .timedOut:
return .timeout
case .userAuthenticationRequired,
.userCancelledAuthentication:
return .authentication
default:
return .network
}
}
private static var httpDateFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
formatter.dateFormat = "EEE',' dd MMM yyyy HH':'mm':'ss z"
return formatter
}
}