e5a83843db
Add resilient usage analytics, OOBE gateway flows, clipboard semantic ranking, purchase recovery, style learning, and managed current-information search.
1035 lines
37 KiB
Swift
1035 lines
37 KiB
Swift
// KeyboardUsageRepository.swift
|
|
// OSGKeyboard · Shared
|
|
//
|
|
// Cross-process daily counters and immutable summary outbox. SQLite WAL plus
|
|
// BEGIN IMMEDIATE prevents lost updates and duplicate day finalization.
|
|
|
|
import Foundation
|
|
import SQLite3
|
|
|
|
public struct KeyboardUsageRepositoryConfiguration: Sendable {
|
|
public static let defaultDatabaseFilename = "keyboard-usage.sqlite3"
|
|
|
|
public let databaseURL: URL?
|
|
public let busyTimeoutMilliseconds: Int32
|
|
|
|
public init(
|
|
databaseURL: URL?,
|
|
busyTimeoutMilliseconds: Int32 = 250
|
|
) {
|
|
self.databaseURL = databaseURL
|
|
self.busyTimeoutMilliseconds = max(0, busyTimeoutMilliseconds)
|
|
}
|
|
|
|
public static func appGroupDefault(
|
|
appGroupIdentifier: String = AppGroup.identifier
|
|
) -> Self {
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
let directory = container?.appendingPathComponent(
|
|
"Library/Application Support/Analytics",
|
|
isDirectory: true
|
|
)
|
|
return Self(
|
|
databaseURL: directory?.appendingPathComponent(defaultDatabaseFilename)
|
|
)
|
|
}
|
|
}
|
|
|
|
public struct KeyboardUsageDailyDiagnostic: Equatable, Sendable {
|
|
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 struct KeyboardUsageOutboxDiagnostic: Equatable, Sendable {
|
|
public let summaryDate: String
|
|
public let clientSummaryID: UUID?
|
|
public let attemptCount: Int
|
|
public let nextAttemptAt: Date
|
|
public let statusCode: Int?
|
|
}
|
|
|
|
public struct KeyboardUsageRepositoryDebugSnapshot: Sendable {
|
|
public let isAvailable: Bool
|
|
public let daily: [KeyboardUsageDailyDiagnostic]
|
|
public let pending: [KeyboardUsageOutboxDiagnostic]
|
|
public let quarantined: [KeyboardUsageOutboxDiagnostic]
|
|
}
|
|
|
|
struct KeyboardUsageLeasedSummary: Sendable {
|
|
let rowID: Int64
|
|
let payload: Data
|
|
let attemptCount: Int
|
|
}
|
|
|
|
struct KeyboardUsageLeasedBatch: Sendable {
|
|
let leaseID: String
|
|
let installationID: UUID
|
|
let summaries: [KeyboardUsageLeasedSummary]
|
|
}
|
|
|
|
public actor KeyboardUsageRepository {
|
|
private enum MetadataKey {
|
|
static let uploadLeaseOwner = "uploadLeaseOwner"
|
|
static let uploadLeaseExpiresAt = "uploadLeaseExpiresAt"
|
|
}
|
|
|
|
private static let maximumSessionCount = 100_000
|
|
|
|
private let configuration: KeyboardUsageRepositoryConfiguration
|
|
private let clock: any AnalyticsWallClock
|
|
private let uuidGenerator: any AnalyticsUUIDGenerating
|
|
private var database: SQLiteDatabase?
|
|
private var isDatabaseAccessSuspended = false
|
|
|
|
public init(
|
|
configuration: KeyboardUsageRepositoryConfiguration = .appGroupDefault(),
|
|
clock: any AnalyticsWallClock = SystemAnalyticsWallClock(),
|
|
uuidGenerator: any AnalyticsUUIDGenerating = SystemAnalyticsUUIDGenerator()
|
|
) {
|
|
self.configuration = configuration
|
|
self.clock = clock
|
|
self.uuidGenerator = uuidGenerator
|
|
}
|
|
|
|
/// Re-enables process-local access before foreground or BGTask work.
|
|
public func resumeDatabaseAccess() {
|
|
isDatabaseAccessSuspended = false
|
|
}
|
|
|
|
/// Closes the process-local connection after all earlier actor work.
|
|
/// Keyboard-extension recording uses a different runtime and is unaffected.
|
|
public func suspendDatabaseAccess() {
|
|
isDatabaseAccessSuspended = true
|
|
database = nil
|
|
}
|
|
|
|
public func record(
|
|
counts: KeyboardUsageCharacterCounts,
|
|
sessionID: UUID,
|
|
installationID: UUID,
|
|
occurredAt: Date,
|
|
environment: AnalyticsEnvironment
|
|
) {
|
|
guard !counts.isEmpty, let database = openDatabaseIfNeeded() else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
let summaryDate = KeyboardUsageUTCDate.string(from: occurredAt)
|
|
try upsertCharacterCounts(
|
|
counts,
|
|
summaryDate: summaryDate,
|
|
installationID: installationID,
|
|
environment: environment,
|
|
database: database
|
|
)
|
|
try updateSession(
|
|
counts: counts,
|
|
sessionID: sessionID,
|
|
summaryDate: summaryDate,
|
|
installationID: installationID,
|
|
database: database
|
|
)
|
|
try finalizeCompletedDays(
|
|
today: KeyboardUsageUTCDate.string(from: clock.now()),
|
|
database: database
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func finalizeCompletedDays() {
|
|
guard let database = openDatabaseIfNeeded() else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
try finalizeCompletedDays(
|
|
today: KeyboardUsageUTCDate.string(from: clock.now()),
|
|
database: database
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func clearAll() {
|
|
guard let database = openDatabaseIfNeeded() else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
try database.execute("DELETE FROM usage_session_fragments")
|
|
try database.execute("DELETE FROM usage_daily_counters")
|
|
try database.execute("DELETE FROM usage_outbox")
|
|
try database.execute("DELETE FROM usage_quarantine")
|
|
try database.execute("DELETE FROM usage_metadata")
|
|
}
|
|
}
|
|
}
|
|
|
|
public func debugSnapshot() -> KeyboardUsageRepositoryDebugSnapshot {
|
|
guard let database = openDatabaseIfNeeded() else {
|
|
return Self.unavailableSnapshot
|
|
}
|
|
do {
|
|
return try database.immediateTransaction {
|
|
KeyboardUsageRepositoryDebugSnapshot(
|
|
isAvailable: true,
|
|
daily: try dailyDiagnostics(database),
|
|
pending: try outboxDiagnostics(
|
|
table: "usage_outbox",
|
|
includesStatus: false,
|
|
database: database
|
|
),
|
|
quarantined: try outboxDiagnostics(
|
|
table: "usage_quarantine",
|
|
includesStatus: true,
|
|
database: database
|
|
)
|
|
)
|
|
}
|
|
} catch {
|
|
return Self.unavailableSnapshot
|
|
}
|
|
}
|
|
|
|
func debugPayload(rowID: Int64, quarantined: Bool = false) -> Data? {
|
|
guard !quarantined else { return nil }
|
|
guard let database = openDatabaseIfNeeded() else { return nil }
|
|
return try? database.query(
|
|
"SELECT payload FROM usage_outbox WHERE id = ?",
|
|
bindings: [.int64(rowID)]
|
|
).first?.data(at: 0)
|
|
}
|
|
|
|
func leaseBatch(
|
|
ownerID: String,
|
|
configuration upload: KeyboardUsageUploadConfiguration
|
|
) -> KeyboardUsageLeasedBatch? {
|
|
guard let database = openDatabaseIfNeeded() else { return nil }
|
|
do {
|
|
return try database.immediateTransaction {
|
|
let now = clock.now()
|
|
let nowInterval = now.timeIntervalSince1970
|
|
let today = KeyboardUsageUTCDate.string(from: now)
|
|
try finalizeCompletedDays(today: today, database: database)
|
|
try quarantineExpiredSummaries(today: today, database: database)
|
|
try releaseExpiredLeases(now: nowInterval, database: database)
|
|
guard try acquireGlobalLease(
|
|
ownerID: ownerID,
|
|
expiresAt: nowInterval + upload.globalLeaseDuration,
|
|
now: nowInterval,
|
|
database: database
|
|
) else {
|
|
return nil
|
|
}
|
|
|
|
guard let installationText = try database.query(
|
|
"""
|
|
SELECT installation_id
|
|
FROM usage_outbox
|
|
WHERE next_attempt_at <= ? AND lease_id IS NULL
|
|
ORDER BY summary_date ASC
|
|
LIMIT 1
|
|
""",
|
|
bindings: [.double(nowInterval)]
|
|
).first?.text(at: 0),
|
|
let installationID = UUID(uuidString: installationText) else {
|
|
try clearGlobalLease(database)
|
|
return nil
|
|
}
|
|
|
|
let rows = try database.query(
|
|
"""
|
|
SELECT id, payload, attempt_count
|
|
FROM usage_outbox
|
|
WHERE installation_id = ?
|
|
AND next_attempt_at <= ?
|
|
AND lease_id IS NULL
|
|
ORDER BY summary_date ASC
|
|
LIMIT ?
|
|
""",
|
|
bindings: [
|
|
.text(installationText),
|
|
.double(nowInterval),
|
|
.int64(Int64(upload.maximumBatchCount))
|
|
]
|
|
)
|
|
let summaries = rows.compactMap { row -> KeyboardUsageLeasedSummary? in
|
|
guard let rowID = row.int64(at: 0),
|
|
let payload = row.data(at: 1),
|
|
let attemptCount = row.int64(at: 2) else {
|
|
return nil
|
|
}
|
|
return KeyboardUsageLeasedSummary(
|
|
rowID: rowID,
|
|
payload: payload,
|
|
attemptCount: Int(attemptCount)
|
|
)
|
|
}
|
|
guard !summaries.isEmpty else {
|
|
try clearGlobalLease(database)
|
|
return nil
|
|
}
|
|
|
|
let leaseID = uuidGenerator.makeUUID().uuidString.lowercased()
|
|
let leaseExpiry = nowInterval + upload.summaryLeaseDuration
|
|
for summary in summaries {
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_outbox
|
|
SET lease_id = ?, lease_expires_at = ?
|
|
WHERE id = ? AND lease_id IS NULL
|
|
""",
|
|
bindings: [
|
|
.text(leaseID),
|
|
.double(leaseExpiry),
|
|
.int64(summary.rowID)
|
|
]
|
|
)
|
|
}
|
|
return KeyboardUsageLeasedBatch(
|
|
leaseID: leaseID,
|
|
installationID: installationID,
|
|
summaries: summaries
|
|
)
|
|
}
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func renewLease(
|
|
ownerID: String,
|
|
leaseID: String,
|
|
configuration upload: KeyboardUsageUploadConfiguration
|
|
) -> Bool {
|
|
guard let database = openDatabaseIfNeeded() else { return false }
|
|
do {
|
|
return try database.immediateTransaction {
|
|
guard try metadata(
|
|
MetadataKey.uploadLeaseOwner,
|
|
database: database
|
|
) == ownerID else {
|
|
return false
|
|
}
|
|
let now = clock.now().timeIntervalSince1970
|
|
try setMetadata(
|
|
String(now + upload.globalLeaseDuration),
|
|
key: MetadataKey.uploadLeaseExpiresAt,
|
|
database: database
|
|
)
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_outbox
|
|
SET lease_expires_at = ?
|
|
WHERE lease_id = ?
|
|
""",
|
|
bindings: [
|
|
.double(now + upload.summaryLeaseDuration),
|
|
.text(leaseID)
|
|
]
|
|
)
|
|
// Account rotation clears the outbox before a new bearer may
|
|
// be used. A leased in-memory batch is valid only while at
|
|
// least one matching row still exists in SQLite.
|
|
guard database.changes > 0 else {
|
|
try clearGlobalLease(database)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func complete(rowIDs: [Int64], leaseID: String) -> Bool {
|
|
guard let database = openDatabaseIfNeeded(), !rowIDs.isEmpty else {
|
|
return false
|
|
}
|
|
do {
|
|
return try database.immediateTransaction {
|
|
var deleted = 0
|
|
for rowID in rowIDs {
|
|
try database.execute(
|
|
"DELETE FROM usage_outbox WHERE id = ? AND lease_id = ?",
|
|
bindings: [.int64(rowID), .text(leaseID)]
|
|
)
|
|
deleted += Int(database.changes)
|
|
}
|
|
return deleted == rowIDs.count
|
|
}
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func scheduleRetry(
|
|
summaries: [KeyboardUsageLeasedSummary],
|
|
leaseID: String,
|
|
delay: TimeInterval
|
|
) {
|
|
guard let database = openDatabaseIfNeeded(), !summaries.isEmpty else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
let nextAttempt = clock.now().addingTimeInterval(max(0, delay))
|
|
.timeIntervalSince1970
|
|
for summary in summaries {
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_outbox
|
|
SET attempt_count = attempt_count + 1,
|
|
next_attempt_at = ?,
|
|
lease_id = NULL,
|
|
lease_expires_at = NULL
|
|
WHERE id = ? AND lease_id = ?
|
|
""",
|
|
bindings: [
|
|
.double(nextAttempt),
|
|
.int64(summary.rowID),
|
|
.text(leaseID)
|
|
]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func quarantine(
|
|
rowIDs: [Int64],
|
|
leaseID: String,
|
|
statusCode: Int
|
|
) {
|
|
guard let database = openDatabaseIfNeeded(), !rowIDs.isEmpty else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
for rowID in rowIDs {
|
|
try moveToQuarantine(
|
|
rowID: rowID,
|
|
leaseID: leaseID,
|
|
statusCode: statusCode,
|
|
database: database
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func release(
|
|
summaries: [KeyboardUsageLeasedSummary],
|
|
leaseID: String
|
|
) {
|
|
guard let database = openDatabaseIfNeeded(), !summaries.isEmpty else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
for summary in summaries {
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_outbox
|
|
SET lease_id = NULL, lease_expires_at = NULL
|
|
WHERE id = ? AND lease_id = ?
|
|
""",
|
|
bindings: [.int64(summary.rowID), .text(leaseID)]
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func releaseGlobalLease(ownerID: String) {
|
|
guard let database = openDatabaseIfNeeded() else { return }
|
|
absorbDatabaseErrors {
|
|
try database.immediateTransaction {
|
|
guard try metadata(
|
|
MetadataKey.uploadLeaseOwner,
|
|
database: database
|
|
) == ownerID else {
|
|
return
|
|
}
|
|
try clearGlobalLease(database)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func upsertCharacterCounts(
|
|
_ counts: KeyboardUsageCharacterCounts,
|
|
summaryDate: String,
|
|
installationID: UUID,
|
|
environment: AnalyticsEnvironment,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
try database.execute(
|
|
"""
|
|
INSERT INTO usage_daily_counters (
|
|
installation_id, summary_date,
|
|
chinese_count, english_count, other_count,
|
|
input_session_count, chinese_only_count, english_only_count,
|
|
mixed_count, other_only_count, app_version, os_version
|
|
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, 0, 0, ?, ?)
|
|
ON CONFLICT(installation_id, summary_date) DO UPDATE SET
|
|
chinese_count = MIN(1000000, chinese_count + excluded.chinese_count),
|
|
english_count = MIN(1000000, english_count + excluded.english_count),
|
|
other_count = MIN(1000000, other_count + excluded.other_count)
|
|
""",
|
|
bindings: [
|
|
.text(installationID.uuidString.lowercased()),
|
|
.text(summaryDate),
|
|
.int64(Int64(counts.chinese)),
|
|
.int64(Int64(counts.english)),
|
|
.int64(Int64(counts.other)),
|
|
.text(environment.appVersion),
|
|
.text(environment.osVersion)
|
|
]
|
|
)
|
|
}
|
|
|
|
private func updateSession(
|
|
counts: KeyboardUsageCharacterCounts,
|
|
sessionID: UUID,
|
|
summaryDate: String,
|
|
installationID: UUID,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
let installationText = installationID.uuidString.lowercased()
|
|
let sessionText = sessionID.uuidString.lowercased()
|
|
let rows = try database.query(
|
|
"""
|
|
SELECT classification, counted
|
|
FROM usage_session_fragments
|
|
WHERE installation_id = ? AND summary_date = ? AND session_id = ?
|
|
""",
|
|
bindings: [
|
|
.text(installationText),
|
|
.text(summaryDate),
|
|
.text(sessionText)
|
|
]
|
|
)
|
|
let incoming = KeyboardUsageSessionClassification(counts: counts)
|
|
if let row = rows.first,
|
|
let rawClassification = row.int64(at: 0),
|
|
let previous = KeyboardUsageSessionClassification(
|
|
rawValue: Int(rawClassification)
|
|
),
|
|
let countedValue = row.int64(at: 1) {
|
|
let merged = previous.merging(counts)
|
|
guard merged != previous else { return }
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_session_fragments
|
|
SET classification = ?
|
|
WHERE installation_id = ? AND summary_date = ? AND session_id = ?
|
|
""",
|
|
bindings: [
|
|
.int64(Int64(merged.rawValue)),
|
|
.text(installationText),
|
|
.text(summaryDate),
|
|
.text(sessionText)
|
|
]
|
|
)
|
|
if countedValue == 1 {
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_daily_counters
|
|
SET \(Self.column(for: previous)) = \(Self.column(for: previous)) - 1,
|
|
\(Self.column(for: merged)) = \(Self.column(for: merged)) + 1
|
|
WHERE installation_id = ? AND summary_date = ?
|
|
""",
|
|
bindings: [.text(installationText), .text(summaryDate)]
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
let inputSessionCount = try database.query(
|
|
"""
|
|
SELECT input_session_count
|
|
FROM usage_daily_counters
|
|
WHERE installation_id = ? AND summary_date = ?
|
|
""",
|
|
bindings: [.text(installationText), .text(summaryDate)]
|
|
).first?.int64(at: 0) ?? 0
|
|
let counted = inputSessionCount < Int64(Self.maximumSessionCount)
|
|
try database.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO usage_session_fragments (
|
|
installation_id, summary_date, session_id, classification, counted
|
|
) VALUES (?, ?, ?, ?, ?)
|
|
""",
|
|
bindings: [
|
|
.text(installationText),
|
|
.text(summaryDate),
|
|
.text(sessionText),
|
|
.int64(Int64(incoming.rawValue)),
|
|
.int64(counted ? 1 : 0)
|
|
]
|
|
)
|
|
guard database.changes == 1, counted else { return }
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_daily_counters
|
|
SET input_session_count = input_session_count + 1,
|
|
\(Self.column(for: incoming)) = \(Self.column(for: incoming)) + 1
|
|
WHERE installation_id = ? AND summary_date = ?
|
|
""",
|
|
bindings: [.text(installationText), .text(summaryDate)]
|
|
)
|
|
}
|
|
|
|
private func finalizeCompletedDays(
|
|
today: String,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
let rows = try database.query(
|
|
"""
|
|
SELECT installation_id, summary_date,
|
|
chinese_count, english_count, other_count,
|
|
input_session_count, chinese_only_count, english_only_count,
|
|
mixed_count, other_only_count, app_version, os_version
|
|
FROM usage_daily_counters
|
|
WHERE summary_date < ?
|
|
ORDER BY summary_date ASC
|
|
""",
|
|
bindings: [.text(today)]
|
|
)
|
|
for row in rows {
|
|
guard let installationText = row.text(at: 0),
|
|
UUID(uuidString: installationText) != nil,
|
|
let summaryDate = row.text(at: 1),
|
|
let chinese = row.int64(at: 2),
|
|
let english = row.int64(at: 3),
|
|
let other = row.int64(at: 4),
|
|
let sessions = row.int64(at: 5),
|
|
let chineseOnly = row.int64(at: 6),
|
|
let englishOnly = row.int64(at: 7),
|
|
let mixed = row.int64(at: 8),
|
|
let otherOnly = row.int64(at: 9),
|
|
let appVersion = row.text(at: 10),
|
|
let osVersion = row.text(at: 11) else {
|
|
continue
|
|
}
|
|
let summary = try KeyboardUsageSummary(
|
|
clientSummaryId: uuidGenerator.makeUUID(),
|
|
summaryDate: summaryDate,
|
|
chineseCharacterCount: Int(chinese),
|
|
englishCharacterCount: Int(english),
|
|
otherCharacterCount: Int(other),
|
|
inputSessionCount: Int(sessions),
|
|
chineseOnlySessionCount: Int(chineseOnly),
|
|
englishOnlySessionCount: Int(englishOnly),
|
|
mixedLanguageSessionCount: Int(mixed),
|
|
otherOnlySessionCount: Int(otherOnly),
|
|
appVersion: appVersion,
|
|
osVersion: osVersion
|
|
)
|
|
let payload = try AnalyticsCanonicalJSON.encode(summary)
|
|
try database.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO usage_outbox (
|
|
installation_id, summary_date, client_summary_id, payload,
|
|
attempt_count, next_attempt_at, lease_id, lease_expires_at,
|
|
created_at
|
|
) VALUES (?, ?, ?, ?, 0, 0, NULL, NULL, ?)
|
|
""",
|
|
bindings: [
|
|
.text(installationText),
|
|
.text(summaryDate),
|
|
.text(summary.clientSummaryId.uuidString.lowercased()),
|
|
.blob(payload),
|
|
.double(clock.now().timeIntervalSince1970)
|
|
]
|
|
)
|
|
try database.execute(
|
|
"""
|
|
DELETE FROM usage_session_fragments
|
|
WHERE installation_id = ? AND summary_date = ?
|
|
""",
|
|
bindings: [.text(installationText), .text(summaryDate)]
|
|
)
|
|
try database.execute(
|
|
"""
|
|
DELETE FROM usage_daily_counters
|
|
WHERE installation_id = ? AND summary_date = ?
|
|
""",
|
|
bindings: [.text(installationText), .text(summaryDate)]
|
|
)
|
|
}
|
|
}
|
|
|
|
private func quarantineExpiredSummaries(
|
|
today: String,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
guard let oldest = KeyboardUsageUTCDate.oldestAcceptedDate(
|
|
relativeTo: today
|
|
) else {
|
|
return
|
|
}
|
|
let rowIDs = try database.query(
|
|
"""
|
|
SELECT id FROM usage_outbox
|
|
WHERE summary_date < ? AND lease_id IS NULL
|
|
""",
|
|
bindings: [.text(oldest)]
|
|
).compactMap { $0.int64(at: 0) }
|
|
for rowID in rowIDs {
|
|
try moveToQuarantine(
|
|
rowID: rowID,
|
|
leaseID: nil,
|
|
statusCode: 0,
|
|
database: database
|
|
)
|
|
}
|
|
}
|
|
|
|
private func moveToQuarantine(
|
|
rowID: Int64,
|
|
leaseID: String?,
|
|
statusCode: Int,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
var clause = ""
|
|
var selectBindings: [SQLiteBinding] = [
|
|
.int64(Int64(statusCode)),
|
|
.double(clock.now().timeIntervalSince1970),
|
|
.int64(rowID)
|
|
]
|
|
var deleteBindings: [SQLiteBinding] = [.int64(rowID)]
|
|
if let leaseID {
|
|
clause = " AND lease_id = ?"
|
|
selectBindings.append(.text(leaseID))
|
|
deleteBindings.append(.text(leaseID))
|
|
}
|
|
try database.execute(
|
|
"""
|
|
INSERT OR IGNORE INTO usage_quarantine (
|
|
summary_date, attempt_count, status_code, quarantined_at
|
|
)
|
|
SELECT summary_date, attempt_count, ?, ?
|
|
FROM usage_outbox
|
|
WHERE id = ?\(clause)
|
|
""",
|
|
bindings: selectBindings
|
|
)
|
|
try database.execute(
|
|
"DELETE FROM usage_outbox WHERE id = ?\(clause)",
|
|
bindings: deleteBindings
|
|
)
|
|
}
|
|
|
|
private static func column(
|
|
for classification: KeyboardUsageSessionClassification
|
|
) -> String {
|
|
switch classification {
|
|
case .chineseOnly:
|
|
return "chinese_only_count"
|
|
case .englishOnly:
|
|
return "english_only_count"
|
|
case .mixedLanguage:
|
|
return "mixed_count"
|
|
case .otherOnly:
|
|
return "other_only_count"
|
|
}
|
|
}
|
|
|
|
private func acquireGlobalLease(
|
|
ownerID: String,
|
|
expiresAt: TimeInterval,
|
|
now: TimeInterval,
|
|
database: SQLiteDatabase
|
|
) throws -> Bool {
|
|
let owner = try metadata(MetadataKey.uploadLeaseOwner, database: database)
|
|
let expiry = try metadata(
|
|
MetadataKey.uploadLeaseExpiresAt,
|
|
database: database
|
|
).flatMap(TimeInterval.init) ?? 0
|
|
guard owner == nil || owner == ownerID || expiry <= now else { return false }
|
|
try setMetadata(
|
|
ownerID,
|
|
key: MetadataKey.uploadLeaseOwner,
|
|
database: database
|
|
)
|
|
try setMetadata(
|
|
String(expiresAt),
|
|
key: MetadataKey.uploadLeaseExpiresAt,
|
|
database: database
|
|
)
|
|
return true
|
|
}
|
|
|
|
private func releaseExpiredLeases(
|
|
now: TimeInterval,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
try database.execute(
|
|
"""
|
|
UPDATE usage_outbox
|
|
SET lease_id = NULL, lease_expires_at = NULL
|
|
WHERE lease_expires_at IS NOT NULL AND lease_expires_at <= ?
|
|
""",
|
|
bindings: [.double(now)]
|
|
)
|
|
}
|
|
|
|
private func clearGlobalLease(_ database: SQLiteDatabase) throws {
|
|
try database.execute(
|
|
"DELETE FROM usage_metadata WHERE key IN (?, ?)",
|
|
bindings: [
|
|
.text(MetadataKey.uploadLeaseOwner),
|
|
.text(MetadataKey.uploadLeaseExpiresAt)
|
|
]
|
|
)
|
|
}
|
|
|
|
private func metadata(
|
|
_ key: String,
|
|
database: SQLiteDatabase
|
|
) throws -> String? {
|
|
try database.query(
|
|
"SELECT value FROM usage_metadata WHERE key = ?",
|
|
bindings: [.text(key)]
|
|
).first?.text(at: 0)
|
|
}
|
|
|
|
private func setMetadata(
|
|
_ value: String,
|
|
key: String,
|
|
database: SQLiteDatabase
|
|
) throws {
|
|
try database.execute(
|
|
"""
|
|
INSERT INTO usage_metadata(key, value) VALUES(?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
""",
|
|
bindings: [.text(key), .text(value)]
|
|
)
|
|
}
|
|
|
|
private func dailyDiagnostics(
|
|
_ database: SQLiteDatabase
|
|
) throws -> [KeyboardUsageDailyDiagnostic] {
|
|
try database.query(
|
|
"""
|
|
SELECT summary_date, chinese_count, english_count, other_count,
|
|
input_session_count, chinese_only_count, english_only_count,
|
|
mixed_count, other_only_count
|
|
FROM usage_daily_counters
|
|
ORDER BY summary_date ASC
|
|
"""
|
|
).compactMap { row in
|
|
guard let date = row.text(at: 0),
|
|
let chinese = row.int64(at: 1),
|
|
let english = row.int64(at: 2),
|
|
let other = row.int64(at: 3),
|
|
let sessions = row.int64(at: 4),
|
|
let chineseOnly = row.int64(at: 5),
|
|
let englishOnly = row.int64(at: 6),
|
|
let mixed = row.int64(at: 7),
|
|
let otherOnly = row.int64(at: 8) else {
|
|
return nil
|
|
}
|
|
return KeyboardUsageDailyDiagnostic(
|
|
summaryDate: date,
|
|
chineseCharacterCount: Int(chinese),
|
|
englishCharacterCount: Int(english),
|
|
otherCharacterCount: Int(other),
|
|
inputSessionCount: Int(sessions),
|
|
chineseOnlySessionCount: Int(chineseOnly),
|
|
englishOnlySessionCount: Int(englishOnly),
|
|
mixedLanguageSessionCount: Int(mixed),
|
|
otherOnlySessionCount: Int(otherOnly)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func outboxDiagnostics(
|
|
table: String,
|
|
includesStatus: Bool,
|
|
database: SQLiteDatabase
|
|
) throws -> [KeyboardUsageOutboxDiagnostic] {
|
|
let statusColumn = includesStatus ? "status_code" : "NULL"
|
|
let nextAttemptColumn = includesStatus ? "quarantined_at" : "next_attempt_at"
|
|
let identifierColumn = includesStatus ? "NULL" : "client_summary_id"
|
|
return try database.query(
|
|
"""
|
|
SELECT summary_date, \(identifierColumn), attempt_count,
|
|
\(nextAttemptColumn), \(statusColumn)
|
|
FROM \(table)
|
|
ORDER BY summary_date ASC
|
|
"""
|
|
).compactMap { row in
|
|
guard let date = row.text(at: 0),
|
|
let attempts = row.int64(at: 2),
|
|
let nextAttempt = row.double(at: 3) else {
|
|
return nil
|
|
}
|
|
return KeyboardUsageOutboxDiagnostic(
|
|
summaryDate: date,
|
|
clientSummaryID: row.text(at: 1).flatMap(UUID.init(uuidString:)),
|
|
attemptCount: Int(attempts),
|
|
nextAttemptAt: Date(timeIntervalSince1970: nextAttempt),
|
|
statusCode: row.int64(at: 4).map(Int.init)
|
|
)
|
|
}
|
|
}
|
|
|
|
private func openDatabaseIfNeeded() -> SQLiteDatabase? {
|
|
guard !isDatabaseAccessSuspended else { return nil }
|
|
if let database { return database }
|
|
guard let url = configuration.databaseURL else { return nil }
|
|
let parent = url.deletingLastPathComponent()
|
|
try? FileManager.default.createDirectory(
|
|
at: parent,
|
|
withIntermediateDirectories: true,
|
|
attributes: Self.directoryProtectionAttributes
|
|
)
|
|
// A second process can be setting WAL/schema pragmas during its first
|
|
// open. Retry that short bootstrap race instead of dropping an input.
|
|
for attempt in 0..<5 {
|
|
do {
|
|
let opened = try SQLiteDatabase(
|
|
url: url,
|
|
busyTimeoutMilliseconds: configuration.busyTimeoutMilliseconds
|
|
)
|
|
try migrate(opened)
|
|
applyFileProtection(to: url)
|
|
database = opened
|
|
return opened
|
|
} catch {
|
|
guard attempt < 4 else { return nil }
|
|
Thread.sleep(forTimeInterval: 0.01 * Double(attempt + 1))
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func migrate(_ database: SQLiteDatabase) throws {
|
|
let version = try database.scalarInt64("PRAGMA user_version") ?? 0
|
|
guard version <= 1 else { return }
|
|
guard version == 0 else { return }
|
|
try database.immediateTransaction {
|
|
try database.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS usage_metadata (
|
|
key TEXT PRIMARY KEY NOT NULL,
|
|
value TEXT NOT NULL
|
|
) WITHOUT ROWID
|
|
"""
|
|
)
|
|
try database.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS usage_daily_counters (
|
|
installation_id TEXT NOT NULL,
|
|
summary_date TEXT NOT NULL,
|
|
chinese_count INTEGER NOT NULL CHECK(chinese_count BETWEEN 0 AND 1000000),
|
|
english_count INTEGER NOT NULL CHECK(english_count BETWEEN 0 AND 1000000),
|
|
other_count INTEGER NOT NULL CHECK(other_count BETWEEN 0 AND 1000000),
|
|
input_session_count INTEGER NOT NULL CHECK(input_session_count BETWEEN 0 AND 100000),
|
|
chinese_only_count INTEGER NOT NULL CHECK(chinese_only_count BETWEEN 0 AND 100000),
|
|
english_only_count INTEGER NOT NULL CHECK(english_only_count BETWEEN 0 AND 100000),
|
|
mixed_count INTEGER NOT NULL CHECK(mixed_count BETWEEN 0 AND 100000),
|
|
other_only_count INTEGER NOT NULL CHECK(other_only_count BETWEEN 0 AND 100000),
|
|
app_version TEXT NOT NULL,
|
|
os_version TEXT NOT NULL,
|
|
PRIMARY KEY(installation_id, summary_date),
|
|
CHECK(chinese_only_count + english_only_count + mixed_count
|
|
+ other_only_count = input_session_count)
|
|
) WITHOUT ROWID
|
|
"""
|
|
)
|
|
try database.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS usage_session_fragments (
|
|
installation_id TEXT NOT NULL,
|
|
summary_date TEXT NOT NULL,
|
|
session_id TEXT NOT NULL,
|
|
classification INTEGER NOT NULL CHECK(classification BETWEEN 0 AND 3),
|
|
counted INTEGER NOT NULL CHECK(counted IN (0, 1)),
|
|
PRIMARY KEY(installation_id, summary_date, session_id)
|
|
) WITHOUT ROWID
|
|
"""
|
|
)
|
|
try database.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS usage_outbox (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
installation_id TEXT NOT NULL,
|
|
summary_date TEXT NOT NULL,
|
|
client_summary_id TEXT UNIQUE NOT NULL,
|
|
payload BLOB NOT NULL,
|
|
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
next_attempt_at REAL NOT NULL DEFAULT 0,
|
|
lease_id TEXT,
|
|
lease_expires_at REAL,
|
|
created_at REAL NOT NULL,
|
|
UNIQUE(installation_id, summary_date)
|
|
)
|
|
"""
|
|
)
|
|
try database.execute(
|
|
"""
|
|
CREATE INDEX IF NOT EXISTS usage_outbox_ready
|
|
ON usage_outbox(next_attempt_at, lease_id, summary_date)
|
|
"""
|
|
)
|
|
try database.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS usage_quarantine (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
summary_date TEXT NOT NULL,
|
|
attempt_count INTEGER NOT NULL,
|
|
status_code INTEGER NOT NULL,
|
|
quarantined_at REAL NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
try database.execute("PRAGMA user_version = 1")
|
|
}
|
|
}
|
|
|
|
private func applyFileProtection(to databaseURL: URL) {
|
|
#if os(iOS)
|
|
for url in [
|
|
databaseURL,
|
|
URL(fileURLWithPath: databaseURL.path + "-wal"),
|
|
URL(fileURLWithPath: databaseURL.path + "-shm")
|
|
] where FileManager.default.fileExists(atPath: url.path) {
|
|
try? FileManager.default.setAttributes(
|
|
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
|
ofItemAtPath: url.path
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private static var directoryProtectionAttributes: [
|
|
FileAttributeKey: Any
|
|
]? {
|
|
#if os(iOS)
|
|
return [
|
|
.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication
|
|
]
|
|
#else
|
|
return nil
|
|
#endif
|
|
}
|
|
|
|
private func absorbDatabaseErrors(_ operation: () throws -> Void) {
|
|
do {
|
|
try operation()
|
|
} catch {
|
|
// Usage metrics must never alter keyboard or host-app control flow.
|
|
}
|
|
}
|
|
|
|
private static let unavailableSnapshot = KeyboardUsageRepositoryDebugSnapshot(
|
|
isAvailable: false,
|
|
daily: [],
|
|
pending: [],
|
|
quarantined: []
|
|
)
|
|
}
|