diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c99ecb..dbdbcff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Focused skill icons**: remove math, indices, arrows, shapes, commerce, keyboard, media, text-formatting, automotive, device, and variable-rendering categories from the custom-skill symbol picker. / **精简技能图标**:从自定义技能图标选择器中移除数学、索引、箭头、形状、商业、键盘、媒体、文本格式、汽车、设备与可变渲染分类。 ### Fixed +- **Suspension-safe analytics**: batch uploads in the foreground or a system-granted refresh task, close SQLite outside authorized execution windows, and keep keyboard extensions recording-only to prevent system termination from database locks. / **安全挂起埋点**:仅在前台或系统授权的刷新任务中批量上传,在授权执行窗口之外关闭 SQLite,同时让键盘扩展仅负责记录,避免数据库锁触发系统终止。 - **Permanent invitation links**: load the account-scoped server invitation profile after sign-in, cache it per account, and keep sharing the exact stable server URL across refreshes and offline failures. / **永久邀请链接**:登录后异步加载账号级服务端邀请资料,按账号缓存,并在刷新或离线失败时持续分享服务端返回的固定链接。 - **Cross-process settings safety**: App Group updates now write only changed fields so a stale main-app or keyboard-extension snapshot cannot overwrite newer unrelated settings. / **跨进程设置安全**:App Group 更新现在只写入发生变化的字段,避免主 App 或键盘扩展的旧快照覆盖其他较新的设置。 - **Account-data freshness**: Settings and Account now share one background-refreshed snapshot, preserve cached content on failure, retry transient read errors, and isolate optional referral outages from core account and credit updates. / **账号数据新鲜度**:设置页与账号页现在共用同一份后台刷新快照,刷新失败时保留缓存内容,对瞬时读取错误自动重试,并避免可选邀请接口故障影响账号与积分更新。 diff --git a/OSGKeyboard/Services/AnalyticsHostService.swift b/OSGKeyboard/Services/AnalyticsHostService.swift index 993c58c..50e6187 100644 --- a/OSGKeyboard/Services/AnalyticsHostService.swift +++ b/OSGKeyboard/Services/AnalyticsHostService.swift @@ -12,40 +12,6 @@ import OSGKeyboardShared import OSLog import UIKit -actor AnalyticsUploadSignal: AnalyticsUploadTriggering { - typealias Action = @Sendable () async -> Void - - private var action: Action? - private var isScheduled = false - private let debounce: Duration - - init(debounce: Duration = .milliseconds(250)) { - self.debounce = debounce - } - - func install(_ action: @escaping Action) { - self.action = action - } - - func requestUpload() { - guard !isScheduled, let action else { return } - isScheduled = true - Task { - try? await Task.sleep(for: debounce) - guard !Task.isCancelled else { - uploadFinished() - return - } - await action() - uploadFinished() - } - } - - private func uploadFinished() { - isScheduled = false - } -} - final class HostAnalyticsBearerBridge: AnalyticsBearerProviding, @unchecked Sendable { static let shared = HostAnalyticsBearerBridge() @@ -112,6 +78,12 @@ struct HostAnalyticsLogger: AnalyticsLogging { @MainActor final class AnalyticsHostService: ObservableObject { + private enum RequestedDatabaseAccess { + case uninitialized + case foreground + case suspended + } + static let shared = AnalyticsHostService() static let backgroundTaskIdentifier = "com.osgkeyboard.ios.analytics-sync" @@ -127,16 +99,24 @@ final class AnalyticsHostService: ObservableObject { qos: .utility ) private var isMonitoringNetwork = false - private var foregroundUploadTask: Task? + private var requestedDatabaseAccess = RequestedDatabaseAccess.uninitialized + private var databaseTransitionTask: Task? + private var databaseTransitionGeneration = 0 + private var firstOpenAcquisitionChannel = AnalyticsAcquisitionChannel.unknown + private var pendingAuthenticatedAccountID: UUID? + private var hasPendingAccountDeletion = false + private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid init( bearerProvider: any AnalyticsBearerProviding = HostAnalyticsBearerBridge.shared, - network: any AnalyticsNetworking = URLSessionAnalyticsNetwork() + network: any AnalyticsNetworking = URLSessionAnalyticsNetwork(), + uploadPolicy: AnalyticsUploadPolicy = .mobileDefault ) { - let signal = AnalyticsUploadSignal() + let signal = AnalyticsUploadSignal(policy: uploadPolicy) uploadSignal = signal + let environment = Self.environment let runtime = AnalyticsRuntime.mainApp( - environment: Self.environment, + environment: environment, uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint), network: network, bearerProvider: bearerProvider, @@ -148,53 +128,64 @@ final class AnalyticsHostService: ObservableObject { Task { await signal.install { - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4) + guard !Task.isCancelled else { return } + await runtime.uploadCoordinator.uploadAvailableEvents( + maximumBatches: uploadPolicy.maximumBatches + ) } } } func prepare(firstOpenAcquisitionChannel: AnalyticsAcquisitionChannel) { + self.firstOpenAcquisitionChannel = firstOpenAcquisitionChannel startNetworkMonitoringIfNeeded() - Task { - await runtime.prepare( - firstOpenAcquisitionChannel: firstOpenAcquisitionChannel - ) - let enabled = await runtime.isEnabled() - await MainActor.run { - self.isEnabled = enabled - } - client.recordSessionActivity() - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8) - } } func appDidBecomeActive() { - client.recordSessionActivity() - requestImmediateUpload(maximumBatches: 8) + requestForegroundDatabaseAccess() + } + + func appWillResignActive() { + requestSuspendedDatabaseAccess() } func appDidEnterBackground() { Self.scheduleBackgroundRefresh() - let identifier = UIApplication.shared.beginBackgroundTask( - withName: "analytics-sync" - ) - guard identifier != .invalid else { return } - let task = Task { - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 2) - await MainActor.run { - UIApplication.shared.endBackgroundTask(identifier) + requestSuspendedDatabaseAccess() + guard backgroundTaskIdentifier == .invalid else { return } + + var identifier: UIBackgroundTaskIdentifier = .invalid + identifier = UIApplication.shared.beginBackgroundTask( + withName: "analytics-quiescence" + ) { [weak self] in + Task { @MainActor in + self?.finishBackgroundTask(identifier) + } + } + guard identifier != .invalid else { return } + backgroundTaskIdentifier = identifier + + let transitionTask = databaseTransitionTask + Task { [weak self] in + await transitionTask?.value + await MainActor.run { + self?.finishBackgroundTask(identifier) } } - foregroundUploadTask = task } func observeAuthenticatedAccount(_ accountID: UUID) async { - _ = await runtime.observeAccount(stableIdentifier: accountID.uuidString) - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 8) + pendingAuthenticatedAccountID = accountID + let transitionTask = databaseTransitionTask + await transitionTask?.value + await processDeferredAccountWork() } func handleAccountDeletion() async { - await runtime.handleAccountDeletion() + hasPendingAccountDeletion = true + let transitionTask = databaseTransitionTask + await transitionTask?.value + await processDeferredAccountWork() } func setEnabled(_ enabled: Bool) { @@ -205,7 +196,7 @@ final class AnalyticsHostService: ObservableObject { self.isEnabled = current } if current { - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 4) + await uploadSignal.requestActivationUpload() } } } @@ -219,15 +210,6 @@ final class AnalyticsHostService: ObservableObject { } } - func requestImmediateUpload(maximumBatches: Int = 4) { - foregroundUploadTask?.cancel() - foregroundUploadTask = Task { - await runtime.uploadCoordinator.uploadAvailableEvents( - maximumBatches: maximumBatches - ) - } - } - static func registerBackgroundTask() { BGTaskScheduler.shared.register( forTaskWithIdentifier: backgroundTaskIdentifier, @@ -238,10 +220,12 @@ final class AnalyticsHostService: ObservableObject { return } let operation = Task { - await AnalyticsHostService.shared.runtime.uploadCoordinator - .uploadAvailableEvents(maximumBatches: 4) - refreshTask.setTaskCompleted(success: !Task.isCancelled) - scheduleBackgroundRefresh() + let success = await AnalyticsHostService.shared + .performBackgroundRefresh() + refreshTask.setTaskCompleted(success: success) + if success { + scheduleBackgroundRefresh() + } } refreshTask.expirationHandler = { operation.cancel() @@ -260,13 +244,118 @@ final class AnalyticsHostService: ObservableObject { isMonitoringNetwork = true pathMonitor.pathUpdateHandler = { path in guard path.status == .satisfied else { return } - Task { @MainActor in - AnalyticsHostService.shared.requestImmediateUpload(maximumBatches: 8) + Task { + await AnalyticsHostService.shared.uploadSignal + .requestActivationUpload() } } pathMonitor.start(queue: monitorQueue) } + private func requestForegroundDatabaseAccess() { + guard requestedDatabaseAccess != .foreground else { return } + requestedDatabaseAccess = .foreground + databaseTransitionGeneration += 1 + let generation = databaseTransitionGeneration + let previous = databaseTransitionTask + let runtime = runtime + let signal = uploadSignal + let client = client + let acquisitionChannel = firstOpenAcquisitionChannel + let task = Task { [weak self] in + await previous?.value + await runtime.repository.resumeDatabaseAccess() + await runtime.prepare( + firstOpenAcquisitionChannel: acquisitionChannel + ) + let enabled = await runtime.isEnabled() + await self?.processDeferredAccountWork() + await signal.setActive(true) + client.recordSessionActivity() + await signal.requestActivationUpload() + await MainActor.run { + guard let self else { return } + self.isEnabled = enabled + self.finishDatabaseTransition(generation: generation) + } + } + databaseTransitionTask = task + } + + private func requestSuspendedDatabaseAccess() { + guard requestedDatabaseAccess != .suspended else { return } + requestedDatabaseAccess = .suspended + databaseTransitionGeneration += 1 + let generation = databaseTransitionGeneration + let previous = databaseTransitionTask + let runtime = runtime + let signal = uploadSignal + let task = Task { [weak self] in + await previous?.value + await signal.pauseAndWait() + await runtime.repository.suspendDatabaseAccess() + await MainActor.run { + self?.finishDatabaseTransition(generation: generation) + } + } + databaseTransitionTask = task + } + + private func processDeferredAccountWork() async { + guard requestedDatabaseAccess == .foreground else { return } + + if hasPendingAccountDeletion { + await runtime.handleAccountDeletion() + hasPendingAccountDeletion = false + } + + guard let accountID = pendingAuthenticatedAccountID else { return } + let observation = await runtime.observeAccount( + stableIdentifier: accountID.uuidString + ) + guard requestedDatabaseAccess == .foreground else { return } + if case .unavailable = observation { + return + } + pendingAuthenticatedAccountID = nil + await uploadSignal.requestActivationUpload() + } + + private func performBackgroundRefresh() async -> Bool { + let transitionTask = databaseTransitionTask + await transitionTask?.value + guard !Task.isCancelled else { return false } + + await runtime.repository.resumeDatabaseAccess() + + await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1) + let success = !Task.isCancelled + + if requestedDatabaseAccess != .foreground { + await runtime.repository.suspendDatabaseAccess() + // A foreground transition may race the final close while this + // actor is re-entrant. Re-open if it won during the suspension. + if requestedDatabaseAccess == .foreground { + await runtime.repository.resumeDatabaseAccess() + } + } + return success + } + + private func finishDatabaseTransition(generation: Int) { + guard databaseTransitionGeneration == generation else { return } + databaseTransitionTask = nil + } + + private func finishBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) { + guard identifier != .invalid, + backgroundTaskIdentifier == identifier else { + return + } + UIApplication.shared.endBackgroundTask(identifier) + backgroundTaskIdentifier = .invalid + } + private static let endpoint = URL( string: "https://account.osglab.com/v1/analytics/events" )! diff --git a/OSGKeyboard/Services/AnalyticsUploadScheduling.swift b/OSGKeyboard/Services/AnalyticsUploadScheduling.swift new file mode 100644 index 0000000..f2f97e1 --- /dev/null +++ b/OSGKeyboard/Services/AnalyticsUploadScheduling.swift @@ -0,0 +1,156 @@ +// AnalyticsUploadScheduling.swift +// OSGKeyboard · Main App +// +// Count- and time-based foreground upload scheduling. Recording remains +// fire-and-forget; networking is single-flight and stops when the app resigns. + +import Foundation +import OSGKeyboardShared + +struct AnalyticsUploadPolicy: Sendable { + static let mobileDefault = Self( + eventThreshold: 20, + flushInterval: .seconds(60), + activationDelay: .seconds(10), + maximumBatches: 2 + ) + + let eventThreshold: Int + let flushInterval: Duration + let activationDelay: Duration + let maximumBatches: Int + + init( + eventThreshold: Int, + flushInterval: Duration, + activationDelay: Duration, + maximumBatches: Int + ) { + self.eventThreshold = max(1, eventThreshold) + self.flushInterval = flushInterval + self.activationDelay = activationDelay + self.maximumBatches = max(1, maximumBatches) + } +} + +actor AnalyticsUploadSignal: AnalyticsUploadTriggering { + typealias Action = @Sendable () async -> Void + + private let policy: AnalyticsUploadPolicy + private var action: Action? + private var isActive = false + private var pendingSignals = 0 + private var timerTask: Task? + private var uploadTask: Task? + + init(policy: AnalyticsUploadPolicy = .mobileDefault) { + self.policy = policy + } + + func install(_ action: @escaping Action) { + self.action = action + if isActive, pendingSignals > 0, uploadTask == nil { + schedule(after: policy.activationDelay, replacingTimer: true) + } + } + + func requestUpload() { + pendingSignals = min(Int.max, pendingSignals + 1) + guard isActive, uploadTask == nil else { return } + + if pendingSignals >= policy.eventThreshold { + schedule(after: .zero, replacingTimer: true) + } else { + schedule(after: policy.flushInterval) + } + } + + /// Starts one bounded backlog drain after the foreground has settled. + func requestActivationUpload() { + pendingSignals = max(1, pendingSignals) + guard isActive, uploadTask == nil else { return } + schedule(after: policy.activationDelay, replacingTimer: true) + } + + func setActive(_ active: Bool) async { + guard active != isActive else { + if active, pendingSignals > 0, uploadTask == nil { + schedule(after: policy.activationDelay) + } + return + } + + isActive = active + if active { + if pendingSignals > 0 { + schedule(after: policy.activationDelay, replacingTimer: true) + } + } else { + await cancelAndWait() + } + } + + /// Cancels timers and networking, then waits until the upload action has + /// unwound. Callers can keep a short UIKit background assertion while this + /// returns so no SQLite transaction survives process suspension. + func pauseAndWait() async { + isActive = false + await cancelAndWait() + } + + private func schedule( + after delay: Duration, + replacingTimer: Bool = false + ) { + guard isActive, action != nil, uploadTask == nil else { return } + if replacingTimer { + timerTask?.cancel() + timerTask = nil + } + guard timerTask == nil else { return } + + timerTask = Task { [weak self] in + do { + try await Task.sleep(for: delay) + } catch { + return + } + await self?.timerFired() + } + } + + private func timerFired() { + timerTask = nil + guard isActive, + uploadTask == nil, + pendingSignals > 0, + let action else { + return + } + + pendingSignals = 0 + uploadTask = Task { [weak self] in + await action() + await self?.uploadFinished() + } + } + + private func uploadFinished() { + uploadTask = nil + guard isActive, pendingSignals > 0 else { return } + let delay: Duration = pendingSignals >= policy.eventThreshold + ? .zero + : policy.flushInterval + schedule(after: delay, replacingTimer: true) + } + + private func cancelAndWait() async { + timerTask?.cancel() + timerTask = nil + + let task = uploadTask + task?.cancel() + await task?.value + uploadTask = nil + } +} diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift index e87984d..8f2e9d9 100644 --- a/OSGKeyboard/Views/MainAppRoot.swift +++ b/OSGKeyboard/Views/MainAppRoot.swift @@ -84,6 +84,9 @@ struct MainAppRoot: View { analytics.prepare( firstOpenAcquisitionChannel: firstOpenAcquisitionChannel ) + if scenePhase == .active { + analytics.appDidBecomeActive() + } OSGDiag.log( "MainAppRoot.onAppear scene=\(String(describing: scenePhase)) " + "onboarding=\(config.hasCompletedOnboarding) \(OSGDiag.memoryTag())", @@ -152,6 +155,8 @@ struct MainAppRoot: View { flowManager.handleScenePhase(phase) if phase == .active { analytics.appDidBecomeActive() + } else if phase == .inactive { + analytics.appWillResignActive() } else if phase == .background { analytics.appDidEnterBackground() } diff --git a/OSGKeyboardExt/Services/AnalyticsExtensionService.swift b/OSGKeyboardExt/Services/AnalyticsExtensionService.swift index 9d87144..02a8a07 100644 --- a/OSGKeyboardExt/Services/AnalyticsExtensionService.swift +++ b/OSGKeyboardExt/Services/AnalyticsExtensionService.swift @@ -1,97 +1,32 @@ // AnalyticsExtensionService.swift // OSGKeyboard · Keyboard Extension // -// The extension records into the shared SQLite queue and only attempts one -// short anonymous batch when Full Access permits network use. +// The extension only records into durable queues. The host app is the sole +// network uploader, avoiding extension-lifecycle and cross-process lock races. import Foundation import OSGKeyboardShared -import OSLog - -private actor ExtensionAnalyticsUploadSignal: AnalyticsUploadTriggering { - typealias Action = @Sendable () async -> Void - - private var action: Action? - private var canUpload = false - private var isUploading = false - - func install(_ action: @escaping Action) { - self.action = action - } - - func setCanUpload(_ canUpload: Bool) { - self.canUpload = canUpload - } - - func requestUpload() { - guard canUpload, !isUploading, let action else { return } - isUploading = true - Task { - await action() - uploadFinished() - } - } - - private func uploadFinished() { - isUploading = false - } -} - -private struct ExtensionAnalyticsLogger: AnalyticsLogging { - private let logger = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.osgkeyboard.ios.keyboard", - category: "analytics" - ) - - func log(_ entry: AnalyticsUploadLogEntry) { - let statusCode = entry.statusCode ?? 0 - let errorCategory = entry.errorCategory?.rawValue ?? "none" - logger.info( - "outcome=\(entry.outcome.rawValue, privacy: .public) count=\(entry.eventCount, privacy: .public) status=\(statusCode, privacy: .public) attempt=\(entry.attempt, privacy: .public) error=\(errorCategory, privacy: .public)" - ) - } -} final class AnalyticsExtensionService: Sendable { static let shared = AnalyticsExtensionService() let client: any AnalyticsClient - private let runtime: AnalyticsRuntime - private let uploadSignal: ExtensionAnalyticsUploadSignal - private init() { - let signal = ExtensionAnalyticsUploadSignal() - uploadSignal = signal + let environment = Self.environment let runtime = AnalyticsRuntime.keyboardExtension( - environment: Self.environment, - uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint), - trigger: signal, - logger: ExtensionAnalyticsLogger() + environment: environment, + uploadConfiguration: AnalyticsUploadConfiguration(endpoint: Self.endpoint) ) - self.runtime = runtime client = runtime.client - - Task { - await signal.install { - await runtime.uploadCoordinator.uploadAvailableEvents(maximumBatches: 1) - } - } } - func recordPresentation(hasFullAccess: Bool) { - Task { - await uploadSignal.setCanUpload(hasFullAccess) - client.recordSessionActivity() - client.recordKeyboardActivated() - } + func recordPresentation(hasFullAccess _: Bool) { + client.recordSessionActivity() + client.recordKeyboardActivated() } - func keyboardWillDisappear() { - Task { - await uploadSignal.setCanUpload(false) - } - } + func keyboardWillDisappear() {} private static let endpoint = URL( string: "https://account.osglab.com/v1/analytics/events" diff --git a/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift b/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift index 513858c..7fa7da7 100644 --- a/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift +++ b/OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests.swift @@ -8,6 +8,38 @@ import Foundation import XCTest final class AnalyticsExtensionPrivacyTests: XCTestCase { + func testKeyboardRuntimeRecordsWithoutAutomaticallyUploading() async throws { + let network = ExtensionAnalyticsNetwork() + let runtime = AnalyticsRuntime.keyboardExtension( + environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"), + repositoryConfiguration: AnalyticsRepositoryConfiguration( + databaseURL: try temporaryDatabaseURL() + ), + uploadConfiguration: AnalyticsUploadConfiguration( + endpoint: URL(string: "https://analytics.test/v1/events")! + ), + network: network, + wallClock: ExtensionAnalyticsClock(), + monotonicClock: ExtensionAnalyticsMonotonicClock(), + uuidGenerator: ExtensionAnalyticsUUIDGenerator(), + random: ExtensionAnalyticsRandomGenerator() + ) + + runtime.client.recordKeyboardActivated() + for _ in 0..<200 { + if await runtime.repository.debugSnapshot().pendingEvents.count == 1 { + break + } + try await Task.sleep(for: .milliseconds(10)) + } + try await Task.sleep(for: .milliseconds(50)) + + let pendingSnapshot = await runtime.repository.debugSnapshot() + let requests = await network.requests() + XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1) + XCTAssertTrue(requests.isEmpty) + } + func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws { let databaseURL = try temporaryDatabaseURL() let network = ExtensionAnalyticsNetwork() diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift index 7255a46..61d2b62 100644 --- a/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsDependencies.swift @@ -174,7 +174,7 @@ public struct AnalyticsRepositoryConfiguration: Sendable { maximumEventCount: Int = 10_000, maximumStoredBytes: Int = 5 * 1_024 * 1_024, eventRetention: TimeInterval = 34 * 24 * 60 * 60, - busyTimeoutMilliseconds: Int32 = 2_000 + busyTimeoutMilliseconds: Int32 = 250 ) { self.databaseURL = databaseURL self.maximumEventCount = max(1, maximumEventCount) diff --git a/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift b/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift index 1c9b1b5..f33aa05 100644 --- a/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift +++ b/OSGKeyboardShared/Features/Analytics/AnalyticsRepository.swift @@ -96,6 +96,7 @@ public actor AnalyticsRepository { private let clock: any AnalyticsWallClock private let uuidGenerator: any AnalyticsUUIDGenerating private var database: SQLiteDatabase? + private var isDatabaseAccessSuspended = false public init( configuration: AnalyticsRepositoryConfiguration = .appGroupDefault(), @@ -107,6 +108,19 @@ public actor AnalyticsRepository { self.uuidGenerator = uuidGenerator } + /// Re-enables process-local access before foreground or BGTask work. + public func resumeDatabaseAccess() { + isDatabaseAccessSuspended = false + } + + /// Runs after earlier actor operations, then closes the process-local + /// connection. Later fire-and-forget records become best-effort no-ops + /// until an explicitly authorized execution window resumes access. + public func suspendDatabaseAccess() { + isDatabaseAccessSuspended = true + database = nil + } + /// The host calls this only after cold-start attribution has been resolved. /// Installation identity, FIRST_OPEN and its marker share one transaction. public func prepare( @@ -926,6 +940,7 @@ public actor AnalyticsRepository { // MARK: - Metadata and setup private func openDatabaseIfNeeded() -> SQLiteDatabase? { + guard !isDatabaseAccessSuspended else { return nil } if let database { return database } diff --git a/OSGKeyboardTests/AnalyticsRepositoryTests.swift b/OSGKeyboardTests/AnalyticsRepositoryTests.swift index e24ba00..6066fff 100644 --- a/OSGKeyboardTests/AnalyticsRepositoryTests.swift +++ b/OSGKeyboardTests/AnalyticsRepositoryTests.swift @@ -452,6 +452,37 @@ final class AnalyticsRepositoryTests: XCTestCase { eventTypes = await repository.debugSnapshot().pendingEvents.compactMap(\.eventType) XCTAssertEqual(Set(eventTypes), [.firstOpen, .aiFeatureSucceeded]) } + + func testSuspendedDatabaseDropsRecordsUntilExplicitResume() async throws { + let repository = AnalyticsRepository( + configuration: AnalyticsRepositoryConfiguration( + databaseURL: try analyticsTemporaryDatabaseURL() + ), + clock: AnalyticsTestWallClock(), + uuidGenerator: AnalyticsTestUUIDGenerator() + ) + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + + await repository.suspendDatabaseAccess() + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + let suspendedSnapshot = await repository.debugSnapshot() + XCTAssertFalse(suspendedSnapshot.isAvailable) + + await repository.resumeDatabaseAccess() + await repository.record( + eventType: .keyboardActivated, + context: analyticsTestContext + ) + let resumedSnapshot = await repository.debugSnapshot() + XCTAssertTrue(resumedSnapshot.isAvailable) + XCTAssertEqual(resumedSnapshot.pendingEvents.count, 2) + } } private extension Array { diff --git a/OSGKeyboardTests/AnalyticsUploadSchedulingTests.swift b/OSGKeyboardTests/AnalyticsUploadSchedulingTests.swift new file mode 100644 index 0000000..6677550 --- /dev/null +++ b/OSGKeyboardTests/AnalyticsUploadSchedulingTests.swift @@ -0,0 +1,179 @@ +// AnalyticsUploadSchedulingTests.swift +// OSGKeyboardTests +// +// Foreground upload policy regression tests for batching and suspension safety. + +@testable import OSGKeyboard +import XCTest + +final class AnalyticsUploadSchedulingTests: XCTestCase { + func testCountThresholdCoalescesSignalsIntoOneUpload() async throws { + let probe = AnalyticsUploadProbe() + let signal = AnalyticsUploadSignal( + policy: AnalyticsUploadPolicy( + eventThreshold: 3, + flushInterval: .seconds(10), + activationDelay: .zero, + maximumBatches: 1 + ) + ) + await signal.install { + await probe.recordUpload() + } + await signal.setActive(true) + + await signal.requestUpload() + await signal.requestUpload() + try await Task.sleep(for: .milliseconds(30)) + let countBeforeThreshold = await probe.uploadCount() + XCTAssertEqual(countBeforeThreshold, 0) + + await signal.requestUpload() + try await waitUntil { await probe.uploadCount() == 1 } + let countAfterThreshold = await probe.uploadCount() + XCTAssertEqual(countAfterThreshold, 1) + } + + func testIntervalFlushesAQueueBelowThreshold() async throws { + let probe = AnalyticsUploadProbe() + let signal = AnalyticsUploadSignal( + policy: AnalyticsUploadPolicy( + eventThreshold: 20, + flushInterval: .milliseconds(20), + activationDelay: .zero, + maximumBatches: 1 + ) + ) + await signal.install { + await probe.recordUpload() + } + await signal.setActive(true) + + await signal.requestUpload() + + try await waitUntil { await probe.uploadCount() == 1 } + let uploadCount = await probe.uploadCount() + XCTAssertEqual(uploadCount, 1) + } + + func testInactiveSignalDefersUntilNextActivation() async throws { + let probe = AnalyticsUploadProbe() + let signal = AnalyticsUploadSignal( + policy: AnalyticsUploadPolicy( + eventThreshold: 2, + flushInterval: .milliseconds(20), + activationDelay: .milliseconds(20), + maximumBatches: 1 + ) + ) + await signal.install { + await probe.recordUpload() + } + + await signal.requestUpload() + await signal.requestUpload() + try await Task.sleep(for: .milliseconds(40)) + let inactiveUploadCount = await probe.uploadCount() + XCTAssertEqual(inactiveUploadCount, 0) + + await signal.setActive(true) + try await waitUntil { await probe.uploadCount() == 1 } + let activeUploadCount = await probe.uploadCount() + XCTAssertEqual(activeUploadCount, 1) + } + + func testLateActionInstallationDoesNotLoseActivationUpload() async throws { + let probe = AnalyticsUploadProbe() + let signal = AnalyticsUploadSignal( + policy: AnalyticsUploadPolicy( + eventThreshold: 20, + flushInterval: .seconds(10), + activationDelay: .milliseconds(20), + maximumBatches: 1 + ) + ) + await signal.setActive(true) + await signal.requestActivationUpload() + + await signal.install { + await probe.recordUpload() + } + + try await waitUntil { await probe.uploadCount() == 1 } + let uploadCount = await probe.uploadCount() + XCTAssertEqual(uploadCount, 1) + } + + func testPauseCancelsAndWaitsForInFlightUpload() async throws { + let probe = AnalyticsUploadProbe() + let signal = AnalyticsUploadSignal( + policy: AnalyticsUploadPolicy( + eventThreshold: 1, + flushInterval: .seconds(10), + activationDelay: .zero, + maximumBatches: 1 + ) + ) + await signal.install { + await probe.recordStarted() + do { + try await Task.sleep(for: .seconds(30)) + } catch { + await probe.recordCancellation() + } + } + await signal.setActive(true) + await signal.requestUpload() + try await waitUntil { await probe.didStart() } + + await signal.pauseAndWait() + + let wasCancelled = await probe.wasCancelled() + XCTAssertTrue(wasCancelled) + } + + private func waitUntil( + timeout: Duration = .seconds(1), + condition: @escaping @Sendable () async -> Bool + ) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while clock.now < deadline { + if await condition() { + return + } + try await Task.sleep(for: .milliseconds(10)) + } + XCTFail("Timed out waiting for analytics upload state") + } +} + +private actor AnalyticsUploadProbe { + private var uploads = 0 + private var started = false + private var cancelled = false + + func recordUpload() { + uploads += 1 + } + + func uploadCount() -> Int { + uploads + } + + func recordStarted() { + started = true + } + + func didStart() -> Bool { + started + } + + func recordCancellation() { + cancelled = true + } + + func wasCancelled() -> Bool { + cancelled + } +} diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json index a34567c..34137f0 100644 --- a/Tests/suite-manifest.json +++ b/Tests/suite-manifest.json @@ -47,6 +47,7 @@ "OSGKeyboardTests/AnalyticsModelTests", "OSGKeyboardTests/AnalyticsRepositoryTests", "OSGKeyboardTests/AnalyticsUploadCoordinatorTests", + "OSGKeyboardTests/AnalyticsUploadSchedulingTests", "OSGKeyboardTests/AnalyticsAIOperationTests", "OSGKeyboardExtTests/AnalyticsExtensionPrivacyTests" ]