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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user