Repository navigation
Prepare iOS App Store review readiness #7862
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c632f6
0a8fa0f
115e634
1700d36
4a90b8d
eef235c
74e61c3
8e60eba
fdc9b58
8004ea8
0e7800c
92a4dcc
bf7ffce
e0f4a7a
f55fe97
5db7943
404fcf0
f261208
a30dc26
27e6527
306d474
eb0b9f4
10cc0aa
83271a2
479f7a3
9e49463
23382c2
3a65478
c13dd6e
260d324
1a8f1d9
041d3c2
ea483cf
df86c0f
beb8193
7deb687
7dff4f5
8314191
21a2eee
d4b7770
5a380f8
e222c5c
8ffa182
a3ed2d5
58a0bbe
6e55515
f1c751c
eee6c37
0c6eb3f
a5999f2
432512f
9f3d8d5
02168b8
de492a5
6a33738
469fbe1
e37c495
c99ed1a
bcb89c0
2b65cd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| /// Synchronous consent state shared by fire-and-forget callers and the actor. | ||
| /// | ||
| /// A generation makes an event captured before a revoke permanently stale even | ||
| /// if consent is enabled again before the actor drains its FIFO. Synchronizing | ||
| /// transport state inside the same critical section also closes the inverse | ||
| /// race where UserDefaults reads enabled before its notification re-enables the | ||
| /// uploader. | ||
| final class AnalyticsConsentGenerationGate: Sendable { | ||
| private let state: AnalyticsCriticalState<(isEnabled: Bool, generation: UInt64)> | ||
|
|
||
| init(isEnabled: Bool) { | ||
| state = AnalyticsCriticalState( | ||
| initialValue: (isEnabled: isEnabled, generation: 0) | ||
| ) | ||
| } | ||
|
|
||
| func snapshot() -> AnalyticsConsentSnapshot { | ||
| state.withCriticalRegion { | ||
| AnalyticsConsentSnapshot(isEnabled: $0.isEnabled, generation: $0.generation) | ||
| } | ||
| } | ||
|
|
||
| /// Reconciles an observed provider value against the snapshot taken before | ||
| /// reading it. If another thread changed consent during that read, the | ||
| /// original snapshot is returned so the caller's submission stays stale. | ||
| func synchronize( | ||
| observedEnabled: Bool, | ||
| basedOn base: AnalyticsConsentSnapshot, | ||
| publish: @Sendable (AnalyticsConsentSnapshot) -> Void | ||
| ) -> AnalyticsConsentSnapshot { | ||
| state.withCriticalRegion { state in | ||
| guard state.generation == base.generation else { return base } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale consent admits post-revoke eventsMedium Severity When Additional Locations (1)Reviewed by Cursor Bugbot for commit 9f3d8d5. Configure here. |
||
| guard state.isEnabled != observedEnabled else { | ||
| return AnalyticsConsentSnapshot( | ||
| isEnabled: state.isEnabled, | ||
| generation: state.generation | ||
| ) | ||
| } | ||
| state.isEnabled = observedEnabled | ||
| state.generation &+= 1 | ||
| let updated = AnalyticsConsentSnapshot( | ||
| isEnabled: state.isEnabled, | ||
| generation: state.generation | ||
| ) | ||
| publish(updated) | ||
| return updated | ||
| } | ||
| } | ||
|
|
||
| func allows(_ snapshot: AnalyticsConsentSnapshot) -> Bool { | ||
| state.withCriticalRegion { state in | ||
| state.isEnabled && state.generation == snapshot.generation | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| internal import CMUXMobileCore | ||
| internal import Foundation | ||
|
|
||
| // Safety: NotificationCenter owns the callback concurrently, while this type's | ||
| // stored token and center are immutable after initialization. The callback only | ||
| // synchronizes injected thread-safe consent/uploader seams and yields into a | ||
| // thread-safe AsyncStream. | ||
| final class AnalyticsConsentRevocationObserver: @unchecked Sendable { | ||
| private let notificationCenter: NotificationCenter | ||
| private let token: any NSObjectProtocol | ||
|
|
||
| init( | ||
| notificationCenter: NotificationCenter, | ||
| consent: any AnalyticsConsentProviding, | ||
| uploader: any AnalyticsUploading, | ||
| generationGate: AnalyticsConsentGenerationGate, | ||
| onConsentChange: @escaping @Sendable (AnalyticsConsentSnapshot) -> Void | ||
| ) { | ||
| self.notificationCenter = notificationCenter | ||
| uploader.setUploadsEnabled(consent.isTelemetryEnabled) | ||
| self.token = notificationCenter.addObserver( | ||
| forName: UserDefaults.didChangeNotification, | ||
| object: nil, | ||
| queue: nil | ||
| ) { _ in | ||
| let base = generationGate.snapshot() | ||
| let observedEnabled = consent.isTelemetryEnabled | ||
| _ = generationGate.synchronize( | ||
| observedEnabled: observedEnabled, | ||
| basedOn: base | ||
| ) { snapshot in | ||
| // Publish transport state before the FIFO command. A capture | ||
| // racing notification delivery therefore cannot be accepted by | ||
| // consent and then dropped by a still-disabled uploader. | ||
| uploader.setUploadsEnabled(snapshot.isEnabled) | ||
| onConsentChange(snapshot) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| deinit { | ||
| notificationCenter.removeObserver(token) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| struct AnalyticsConsentSnapshot: Equatable, Sendable { | ||
| let isEnabled: Bool | ||
| let generation: UInt64 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| internal import os | ||
|
|
||
| /// Synchronous state for non-async telemetry entrypoints and revocation hooks. | ||
| final class AnalyticsCriticalState<State: Sendable>: Sendable { | ||
| // lint:allow lock - synchronous consent and cancellation entrypoints cannot await an actor without reopening revoke races. | ||
| private let state: OSAllocatedUnfairLock<State> | ||
|
|
||
| init(initialValue: State) { | ||
| state = .init(initialState: initialValue) | ||
| } | ||
|
|
||
| func withCriticalRegion<Result: Sendable>( | ||
| _ body: @Sendable (inout State) throws -> sending Result | ||
| ) rethrows -> sending Result { | ||
| try state.withLock(body) | ||
| } | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.