feat(app): add resilient Flow recovery and privacy-safe analytics
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:
@@ -0,0 +1,138 @@
|
||||
// AnalyticsExtensionPrivacyTests.swift
|
||||
// OSGKeyboardExtTests
|
||||
//
|
||||
// The keyboard analytics runtime is anonymous and emits only typed dimensions.
|
||||
|
||||
import Foundation
|
||||
@testable import OSGKeyboardShared
|
||||
import XCTest
|
||||
|
||||
final class AnalyticsExtensionPrivacyTests: XCTestCase {
|
||||
func testKeyboardRuntimeUploadsWithoutAuthorizationOrSensitiveInputFields() async throws {
|
||||
let databaseURL = try temporaryDatabaseURL()
|
||||
let network = ExtensionAnalyticsNetwork()
|
||||
let runtime = AnalyticsRuntime.keyboardExtension(
|
||||
environment: AnalyticsEnvironment(appVersion: "2.0.0", osVersion: "26.0"),
|
||||
repositoryConfiguration: AnalyticsRepositoryConfiguration(
|
||||
databaseURL: databaseURL
|
||||
),
|
||||
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))
|
||||
}
|
||||
let pendingSnapshot = await runtime.repository.debugSnapshot()
|
||||
XCTAssertEqual(pendingSnapshot.pendingEvents.count, 1)
|
||||
|
||||
await runtime.uploadCoordinator.uploadAvailableEvents()
|
||||
|
||||
let requests = await network.requests()
|
||||
let request = try XCTUnwrap(requests.first)
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
XCTAssertNil(
|
||||
request.headers.first {
|
||||
$0.key.caseInsensitiveCompare("Authorization") == .orderedSame
|
||||
}
|
||||
)
|
||||
let upload = try JSONDecoder().decode(
|
||||
AnalyticsUploadRequest.self,
|
||||
from: request.body
|
||||
)
|
||||
XCTAssertEqual(upload.events.count, 1)
|
||||
XCTAssertEqual(upload.events.first?.surface, .keyboard)
|
||||
XCTAssertEqual(upload.events.first?.eventType, .keyboardActivated)
|
||||
|
||||
let root = try XCTUnwrap(
|
||||
JSONSerialization.jsonObject(with: request.body) as? [String: Any]
|
||||
)
|
||||
let eventObjects = try XCTUnwrap(root["events"] as? [[String: Any]])
|
||||
let event = try XCTUnwrap(eventObjects.first)
|
||||
XCTAssertFalse(event.keys.contains("properties"))
|
||||
XCTAssertFalse(event.keys.contains("text"))
|
||||
XCTAssertFalse(event.keys.contains("prompt"))
|
||||
XCTAssertFalse(event.keys.contains("transcript"))
|
||||
}
|
||||
|
||||
private func temporaryDatabaseURL() throws -> URL {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("OSGKeyboardExtensionAnalyticsTests", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
return directory.appendingPathComponent("analytics.sqlite3")
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExtensionAnalyticsClock: AnalyticsWallClock {
|
||||
func now() -> Date {
|
||||
Date(timeIntervalSince1970: 1_700_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExtensionAnalyticsMonotonicClock: AnalyticsMonotonicClock {
|
||||
func nowNanoseconds() -> UInt64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private final class ExtensionAnalyticsUUIDGenerator:
|
||||
AnalyticsUUIDGenerating,
|
||||
@unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value = 1
|
||||
|
||||
func makeUUID() -> UUID {
|
||||
lock.withLock {
|
||||
defer { value += 1 }
|
||||
return UUID(
|
||||
uuidString: String(
|
||||
format: "00000000-0000-0000-0000-%012x",
|
||||
value
|
||||
)
|
||||
)!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ExtensionAnalyticsRandomGenerator: AnalyticsRandomGenerating {
|
||||
func next(upperBound: UInt64) -> UInt64 {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
private actor ExtensionAnalyticsNetwork: AnalyticsNetworking {
|
||||
private var captured: [AnalyticsHTTPRequest] = []
|
||||
|
||||
func send(_ request: AnalyticsHTTPRequest) async throws -> AnalyticsHTTPResponse {
|
||||
captured.append(request)
|
||||
let upload = try JSONDecoder().decode(
|
||||
AnalyticsUploadRequest.self,
|
||||
from: request.body
|
||||
)
|
||||
return AnalyticsHTTPResponse(
|
||||
statusCode: 200,
|
||||
headers: [:],
|
||||
body: Data(
|
||||
#"{"accepted":\#(upload.events.count),"replayed":0}"#.utf8
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func requests() -> [AnalyticsHTTPRequest] {
|
||||
captured
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user