feat(app): add resilient Flow recovery and privacy-safe analytics
CI / Validate manifests (push) Has been cancelled
CI / SwiftLint (push) Has been cancelled
CI / iOS / Extension (push) Has been cancelled
CI / macOS (push) Has been cancelled

Unify PiP recovery across startup and foreground transitions, surface actionable status, and add privacy-safe analytics plus the refreshed onboarding and account experience. Update documentation assets and advance the release build to 84.
This commit is contained in:
Rocky
2026-08-20 20:22:20 +08:00
parent ee7de5c934
commit 10bc457c72
69 changed files with 7891 additions and 598 deletions
@@ -0,0 +1,308 @@
// AnalyticsUploadCoordinatorTests.swift
// OSGKeyboardTests
//
// HTTP outcome, authentication, retry, and poison-event isolation coverage.
import Foundation
@testable import OSGKeyboardShared
import XCTest
final class AnalyticsUploadCoordinatorTests: XCTestCase {
private let endpoint = URL(string: "https://analytics.test/v1/events")!
func testExactAcceptedAndReplayedCountDeletesUsingOriginalPayloadBytes() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 2)
let payloads = try await analyticsPendingPayloads(repository: repository)
let expectedBody = analyticsRequestBody(payloads: payloads)
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 1, replayed: 1))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
let requests = await network.requests()
let request = try XCTUnwrap(requests.first)
XCTAssertEqual(requests.count, 1)
XCTAssertEqual(request.body, expectedBody)
XCTAssertEqual(request.url, endpoint)
XCTAssertEqual(request.headers["Content-Type"], "application/json")
XCTAssertNil(request.headers["Authorization"])
let topLevel = try XCTUnwrap(
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
)
XCTAssertEqual(Set(topLevel.keys), ["events"])
XCTAssertEqual((topLevel["events"] as? [Any])?.count, 2)
}
func testCountMismatchRetriesWithoutMutatingPayloadOrEventID() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 2)
let originalPayloads = try await analyticsPendingPayloads(repository: repository)
let originalIDs = try originalPayloads.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
}
let network = AnalyticsQueueNetwork([
.response(analyticsSuccessResponse(accepted: 1, replayed: 0))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock,
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.map(\.attemptCount), [1, 1])
XCTAssertEqual(
snapshot.pendingEvents.map(\.nextAttemptAt),
[clock.now().addingTimeInterval(1), clock.now().addingTimeInterval(1)]
)
let retriedPayloads = try await analyticsPendingPayloads(repository: repository)
XCTAssertEqual(retriedPayloads, originalPayloads)
let retriedIDs = try retriedPayloads.map {
try JSONDecoder().decode(AnalyticsEvent.self, from: $0).clientEventId
}
XCTAssertEqual(retriedIDs, originalIDs)
}
func testNetwork408429And5xxScheduleBoundedBackoff() async throws {
let cases: [(AnalyticsQueueNetwork.Outcome, TimeInterval)] = [
(.urlError(.notConnectedToInternet), 1),
(.response(analyticsHTTPResponse(statusCode: 408)), 1),
(
.response(
analyticsHTTPResponse(
statusCode: 429,
headers: ["rEtRy-AfTeR": "120"]
)
),
120
),
(.response(analyticsHTTPResponse(statusCode: 503)), 1)
]
for (index, testCase) in cases.enumerated() {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(
clock: clock,
eventCount: 1,
uuidStart: 100 * (index + 1)
)
let originalPayloads = try await analyticsPendingPayloads(
repository: repository
)
let originalPayload = try XCTUnwrap(originalPayloads.first)
let network = AnalyticsQueueNetwork([testCase.0])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock,
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
let diagnostic = try XCTUnwrap(snapshot.pendingEvents.first)
XCTAssertEqual(diagnostic.attemptCount, 1, "case \(index)")
XCTAssertEqual(
diagnostic.nextAttemptAt,
clock.now().addingTimeInterval(testCase.1),
"case \(index)"
)
let storedPayload = await repository.debugPayloadBytes(rowID: diagnostic.rowID)
XCTAssertEqual(storedPayload, originalPayload, "case \(index)")
}
}
func testUnauthorizedRefreshesOnceAndRetriesWithNewAuthorization() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 1)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 401)),
.response(analyticsSuccessResponse(accepted: 1))
])
let bearer = AnalyticsTestBearerProvider(
initialToken: "old-token",
refreshedToken: "new-token"
)
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: uploadConfiguration(),
network: network,
bearerProvider: bearer,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: AnalyticsTestRandomGenerator()
)
await coordinator.uploadAvailableEvents()
let requests = await network.requests()
XCTAssertEqual(requests.count, 2)
XCTAssertEqual(requests[0].headers["Authorization"], "Bearer old-token")
XCTAssertEqual(requests[1].headers["Authorization"], "Bearer new-token")
XCTAssertEqual(requests[0].body, requests[1].body)
let refreshInputs = await bearer.recordedRefreshInputs()
XCTAssertEqual(refreshInputs.count, 1)
XCTAssertEqual(refreshInputs[0], "old-token")
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
}
func testSecondUnauthorizedDoesNotRefreshAgainAndSchedulesLongRetry() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 1)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 401)),
.response(analyticsHTTPResponse(statusCode: 401))
])
let bearer = AnalyticsTestBearerProvider(
initialToken: "old-token",
refreshedToken: "new-token"
)
let configuration = uploadConfiguration(maximumBackoff: 600)
let coordinator = AnalyticsUploadCoordinator(
repository: repository,
configuration: configuration,
network: network,
bearerProvider: bearer,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: AnalyticsTestRandomGenerator(.upperBound)
)
await coordinator.uploadAvailableEvents()
let refreshInputs = await bearer.recordedRefreshInputs()
XCTAssertEqual(refreshInputs.count, 1)
let snapshot = await repository.debugSnapshot()
XCTAssertEqual(snapshot.pendingEvents.first?.attemptCount, 1)
XCTAssertEqual(
snapshot.pendingEvents.first?.nextAttemptAt,
clock.now().addingTimeInterval(600)
)
}
func testValidationStatusesRecursivelyIsolateOnlyPoisonEvent() async throws {
for statusCode in [400, 409, 422] {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(
clock: clock,
eventCount: 4,
uuidStart: statusCode
)
let events = try await analyticsDecodePendingEvents(repository: repository)
let poisonID = try XCTUnwrap(events.dropFirst().first?.clientEventId)
let network = AnalyticsPoisonNetwork(
statusCode: statusCode,
poisonID: poisonID
)
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty, "HTTP \(statusCode)")
XCTAssertEqual(snapshot.quarantinedEvents.count, 1, "HTTP \(statusCode)")
XCTAssertEqual(
snapshot.quarantinedEvents.first?.reason,
"http\(statusCode)"
)
let quarantinedRowID = try XCTUnwrap(
snapshot.quarantinedEvents.first?.rowID
)
let quarantinedPayload = await repository.debugPayloadBytes(
rowID: quarantinedRowID,
quarantined: true
)
let quarantinedEvent = try JSONDecoder().decode(
AnalyticsEvent.self,
from: try XCTUnwrap(quarantinedPayload)
)
XCTAssertEqual(quarantinedEvent.clientEventId, poisonID)
let requests = await network.requests()
XCTAssertGreaterThan(requests.count, 1)
}
}
func testOtherPermanent4xxQuarantinesWholeBatchWithoutRetry() async throws {
let clock = AnalyticsTestWallClock()
let repository = try await makeRepository(clock: clock, eventCount: 3)
let network = AnalyticsQueueNetwork([
.response(analyticsHTTPResponse(statusCode: 403))
])
let coordinator = makeCoordinator(
repository: repository,
network: network,
clock: clock
)
await coordinator.uploadAvailableEvents()
let snapshot = await repository.debugSnapshot()
XCTAssertTrue(snapshot.pendingEvents.isEmpty)
XCTAssertEqual(snapshot.quarantinedEvents.count, 3)
XCTAssertEqual(Set(snapshot.quarantinedEvents.map(\.reason)), ["http403"])
let requests = await network.requests()
XCTAssertEqual(requests.count, 1)
}
private func makeRepository(
clock: AnalyticsTestWallClock,
eventCount: Int,
uuidStart: Int = 1
) async throws -> AnalyticsRepository {
let repository = AnalyticsRepository(
configuration: AnalyticsRepositoryConfiguration(
databaseURL: try analyticsTemporaryDatabaseURL()
),
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: uuidStart)
)
await analyticsRecordKeyboardEvents(
count: eventCount,
repository: repository
)
return repository
}
private func uploadConfiguration(
maximumBackoff: TimeInterval = 600
) -> AnalyticsUploadConfiguration {
AnalyticsUploadConfiguration(
endpoint: endpoint,
maximumBackoff: maximumBackoff
)
}
private func makeCoordinator(
repository: AnalyticsRepository,
network: some AnalyticsNetworking,
clock: AnalyticsTestWallClock,
random: AnalyticsTestRandomGenerator = AnalyticsTestRandomGenerator()
) -> AnalyticsUploadCoordinator {
AnalyticsUploadCoordinator(
repository: repository,
configuration: uploadConfiguration(),
network: network,
clock: clock,
uuidGenerator: AnalyticsTestUUIDGenerator(startingAt: 5_000),
random: random
)
}
}