mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.
A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.
## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
autonumber
participant App as Buzz iOS app
participant iOS
participant Relay as Buzz relay
participant Attest as Apple App Attest
participant Gateway as Push gateway
App->>Relay: Fetch NIP-11 push capability
Relay-->>App: Push profile, current relay public key, and limits
par
App->>iOS: Request notification permission
iOS-->>App: Permission result
and
App->>iOS: Register for remote notifications
iOS-->>App: Device token
end
App->>Gateway: Request installation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Attest installation transcript
Attest-->>App: Attestation proof
App->>Gateway: Enroll device token and proof
Gateway-->>App: Installation handle
App->>Gateway: Request delegation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Assert relay-key delegation
Attest-->>App: Assertion
App->>Gateway: Create delegation
Gateway-->>App: Opaque endpoint grant
App->>Relay: Publish encrypted push lease and filters
Relay-->>App: Lease acknowledged
```
## Push-time flow
When a notification-eligible event is received by the relay:
```mermaid
%%{init: {
"sequence": {
"actorMargin": 20,
"width": 110,
"messageMargin": 18,
"diagramMarginX": 8,
"wrap": true
}
}}%%
sequenceDiagram
autonumber
participant Relay as Buzz relay
participant Gateway as Push gateway
participant APNs as Apple Push<br/>Notification service
participant iOS
participant NSE as Notification service<br/>extension
Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization
Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
APNs-->>Gateway: 200 OK: request accepted
Gateway-->>Relay: 200 OK: accepted status
APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
iOS->>NSE: Invoke extension<br/>original notification content
NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003
NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```
relay → push gateway → APNs -> NSE -> Notification Center
## Known limitations
The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.
The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.
This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.
## Validation
Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:
- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.
Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.
## Independent pre-reviews
- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
1206 lines
39 KiB
Swift
1206 lines
39 KiB
Swift
import AVFoundation
|
|
import Flutter
|
|
import UIKit
|
|
import UserNotifications
|
|
import XCTest
|
|
|
|
@testable import Buzz
|
|
|
|
class RunnerTests: XCTestCase {
|
|
|
|
func testPushAuthorizationStatusNamesCoverDisplayPermissionStates() {
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.notDetermined), "notDetermined")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.denied), "denied")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.authorized), "authorized")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.provisional), "provisional")
|
|
XCTAssertEqual(AppDelegate.pushAuthorizationStatusName(.ephemeral), "ephemeral")
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorBoundsAndReactivates() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 15)
|
|
|
|
for peer in 0..<15 {
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: peer, levelDbov: -20),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
}
|
|
let rejected = selector.activate(peerIndex: 15, levelDbov: -20)
|
|
XCTAssertEqual(
|
|
rejected,
|
|
HuddleTalkerSelection(accepted: false, evictedPeerIndex: nil)
|
|
)
|
|
var allocationCount = 0
|
|
XCTAssertNil(rejected.allocateIfAccepted {
|
|
allocationCount += 1
|
|
return NSObject()
|
|
})
|
|
XCTAssertEqual(allocationCount, 0)
|
|
XCTAssertEqual(Set(selector.active.keys), Set(0..<15))
|
|
|
|
selector.remove(7)
|
|
XCTAssertFalse(selector.active.keys.contains(7))
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 7, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertTrue(selector.active.keys.contains(7))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorUsesRecentActivity() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 2)
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 2, levelDbov: -20),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -10),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 9, levelDbov: -5),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: 2)
|
|
)
|
|
XCTAssertEqual(Set(selector.active.keys), Set([4, 9]))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorDoesNotChurnAtEqualLevels() {
|
|
var selector = HuddleActiveTalkerSelector(capacity: 2)
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 4, levelDbov: -127),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 2, levelDbov: -127),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: nil)
|
|
)
|
|
let rejected = selector.activate(peerIndex: 9, levelDbov: -127)
|
|
XCTAssertEqual(
|
|
rejected,
|
|
HuddleTalkerSelection(accepted: false, evictedPeerIndex: nil)
|
|
)
|
|
XCTAssertNil(rejected.allocateIfAccepted { NSObject() })
|
|
XCTAssertNil(selector.active[9])
|
|
XCTAssertEqual(Set(selector.active.keys), Set([2, 4]))
|
|
}
|
|
|
|
func testHuddleActiveTalkerSelectorExpiresInactiveSlots() {
|
|
var now: TimeInterval = 0
|
|
var selector = HuddleActiveTalkerSelector(
|
|
capacity: 2,
|
|
now: { now },
|
|
inactivityTimeout: 1
|
|
)
|
|
|
|
_ = selector.activate(peerIndex: 4, levelDbov: -10)
|
|
now = 0.999
|
|
_ = selector.activate(peerIndex: 2, levelDbov: -5)
|
|
now = 1
|
|
|
|
XCTAssertEqual(
|
|
selector.activate(peerIndex: 9, levelDbov: -30),
|
|
HuddleTalkerSelection(accepted: true, evictedPeerIndex: 4)
|
|
)
|
|
XCTAssertEqual(Set(selector.active.keys), Set([2, 9]))
|
|
}
|
|
|
|
func testHuddlePacketJitterQueueReordersAndRejectsStaleDuplicates() {
|
|
var queue = HuddlePacketJitterQueue(capacity: 3, startPackets: 2)
|
|
queue.enqueue(remotePacket(sequence: 11))
|
|
queue.enqueue(remotePacket(sequence: 10))
|
|
XCTAssertEqual(queue.packets.map(\.sequence), [10, 11])
|
|
XCTAssertEqual(queue.drainOne()?.sequence, 10)
|
|
queue.enqueue(remotePacket(sequence: 10))
|
|
queue.enqueue(remotePacket(sequence: 12))
|
|
XCTAssertEqual(queue.packets.map(\.sequence), [11, 12])
|
|
}
|
|
|
|
private func remotePacket(sequence: Int) -> HuddleRemoteOpusPacket {
|
|
HuddleRemoteOpusPacket(
|
|
peerIndex: 1,
|
|
sequence: sequence,
|
|
timestamp48k: Int64(sequence * 960),
|
|
levelDbov: -20,
|
|
opus: Data([1])
|
|
)
|
|
}
|
|
|
|
func testHuddleOpusCodecRoundTripsFixedV2Frame() throws {
|
|
let encoder = try HuddleOpusEncoder()
|
|
let decoder = try HuddleOpusDecoder()
|
|
let samples = (0..<HuddleAudioFormats.frameSamples).map { index in
|
|
Float(sin(2 * .pi * 440 * Double(index) / HuddleAudioFormats.sampleRate))
|
|
}
|
|
|
|
let packet = try encoder.encode(samples)
|
|
let decoded = try decoder.decode(packet)
|
|
|
|
XCTAssertFalse(packet.isEmpty)
|
|
XCTAssertLessThanOrEqual(packet.count, HuddleAudioFormats.maximumOpusPacketBytes)
|
|
XCTAssertGreaterThan(decoded.frameLength, 0)
|
|
XCTAssertLessThanOrEqual(
|
|
decoded.frameLength,
|
|
AVAudioFrameCount(HuddleAudioFormats.frameSamples)
|
|
)
|
|
}
|
|
|
|
func testHuddleAudioLevelsReportDbovWithoutPersistingAudio() {
|
|
let silence = Array(repeating: Float.zero, count: HuddleAudioFormats.frameSamples)
|
|
let halfScale = Array(repeating: Float(0.5), count: HuddleAudioFormats.frameSamples)
|
|
|
|
XCTAssertEqual(HuddleAudioLevels.rmsDbov(silence), -127)
|
|
XCTAssertEqual(HuddleAudioLevels.peakDbov(silence), -127)
|
|
XCTAssertEqual(HuddleAudioLevels.rmsDbov(halfScale), -6)
|
|
XCTAssertEqual(HuddleAudioLevels.peakDbov(halfScale), -6)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesPreserveAudioDelay() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 1, preferredTimescale: 600),
|
|
audioStart: CMTime(seconds: 1.5, preferredTimescale: 600)
|
|
)
|
|
|
|
XCTAssertEqual(CMTimeCompare(times.video, .zero), 0)
|
|
XCTAssertEqual(
|
|
CMTimeCompare(
|
|
times.audio ?? .invalid,
|
|
CMTime(seconds: 0.5, preferredTimescale: 600)
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesPreserveVideoDelay() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 2, preferredTimescale: 600),
|
|
audioStart: CMTime(seconds: 1, preferredTimescale: 600)
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
CMTimeCompare(
|
|
times.video,
|
|
CMTime(seconds: 1, preferredTimescale: 600)
|
|
),
|
|
0
|
|
)
|
|
XCTAssertEqual(CMTimeCompare(times.audio ?? .invalid, .zero), 0)
|
|
}
|
|
|
|
func testRelativeTrackInsertionTimesZeroBasesVideoWithoutAudio() {
|
|
let times = AppDelegate.relativeTrackInsertionTimes(
|
|
videoStart: CMTime(seconds: 3, preferredTimescale: 600),
|
|
audioStart: nil
|
|
)
|
|
|
|
XCTAssertEqual(CMTimeCompare(times.video, .zero), 0)
|
|
XCTAssertNil(times.audio)
|
|
}
|
|
|
|
@MainActor
|
|
func testExpandedAttachmentSurfaceDismissesKeyboard() {
|
|
let window = KeyboardDismissalSpyWindow()
|
|
|
|
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: window)
|
|
|
|
XCTAssertTrue(window.didForceEndEditing)
|
|
}
|
|
|
|
func testExpandedAttachmentSurfaceMeasuresKeyboardOverlap() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 544,
|
|
width: 390,
|
|
height: 300
|
|
)
|
|
),
|
|
300
|
|
)
|
|
XCTAssertEqual(
|
|
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
keyboardLayoutFrame: CGRect(x: 0, y: 844, width: 390, height: 0)
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuReturnsToKeyboardDismissedAnchor() {
|
|
let anchorBounds = CGRect(x: 0, y: 0, width: 44, height: 44)
|
|
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverAnchorLayout.sourceRect(
|
|
anchorBounds: anchorBounds,
|
|
keyboardDismissalOffset: 300,
|
|
isExpanded: true
|
|
),
|
|
anchorBounds.offsetBy(dx: 0, dy: 340)
|
|
)
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverAnchorLayout.sourceRect(
|
|
anchorBounds: anchorBounds,
|
|
keyboardDismissalOffset: 300,
|
|
isExpanded: false
|
|
),
|
|
anchorBounds.offsetBy(dx: 0, dy: 300)
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuKeepsKeyboardWhenMenuFitsAboveTrigger() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: CGRect(x: 320, y: 480, width: 44, height: 44),
|
|
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
|
|
safeAreaInsets: UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 544,
|
|
width: 390,
|
|
height: 300
|
|
),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuDismissesKeyboardAndRepositionsInCompactHeight() {
|
|
let sourceRect = CGRect(x: 760, y: 168, width: 44, height: 44)
|
|
let keyboardDismissalOffset =
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: sourceRect,
|
|
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
|
|
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
|
|
keyboardLayoutFrame: CGRect(
|
|
x: 0,
|
|
y: 228,
|
|
width: 844,
|
|
height: 162
|
|
),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
)
|
|
|
|
XCTAssertEqual(keyboardDismissalOffset, 162)
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.sourceRect(
|
|
sourceRect,
|
|
keyboardDismissalOffset: keyboardDismissalOffset
|
|
),
|
|
sourceRect.offsetBy(dx: 0, dy: 162)
|
|
)
|
|
}
|
|
|
|
func testAttachmentMenuDoesNotMoveWithoutSoftwareKeyboard() {
|
|
XCTAssertEqual(
|
|
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
|
|
sourceRect: CGRect(x: 760, y: 168, width: 44, height: 44),
|
|
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
|
|
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
|
|
keyboardLayoutFrame: CGRect(x: 0, y: 390, width: 844, height: 0),
|
|
menuHeight: NativeAttachmentMenuLayout.size(
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
).height
|
|
),
|
|
0
|
|
)
|
|
}
|
|
|
|
func testNativeAttachmentMenuUsesRoomyRowsAndInsets() {
|
|
let traits = UITraitCollection(preferredContentSizeCategory: .large)
|
|
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
|
|
|
|
XCTAssertEqual(size.width, 216)
|
|
XCTAssertEqual(size.height, 264)
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.contentPadding, 16)
|
|
XCTAssertEqual(
|
|
NativeAttachmentMenuLayout.itemHeight(compatibleWith: traits),
|
|
52
|
|
)
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.itemSpacing, 8)
|
|
XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3)
|
|
}
|
|
|
|
func testNativeAttachmentMenuUsesInterAndSharedPopoverChrome() {
|
|
let font = NativeAttachmentMenuTypography.font(
|
|
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
|
|
)
|
|
var didSelect = false
|
|
let button = makeNativeAttachmentMenuButton(
|
|
title: "Photos",
|
|
symbol: "photo",
|
|
action: { didSelect = true }
|
|
)
|
|
let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first
|
|
|
|
XCTAssertTrue(font.fontName.hasPrefix("Inter"))
|
|
XCTAssertTrue(titleLabel?.font.fontName.hasPrefix("Inter") == true)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.cornerRadius, 20)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.borderWidth, 1)
|
|
XCTAssertEqual(NativeAttachmentPopoverStyle.shadowOpacity, 0.18)
|
|
|
|
button.sendActions(for: .primaryActionTriggered)
|
|
XCTAssertTrue(didSelect)
|
|
}
|
|
|
|
func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() {
|
|
let traits = UITraitCollection(
|
|
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
|
)
|
|
let itemHeight = NativeAttachmentMenuLayout.itemHeight(
|
|
compatibleWith: traits
|
|
)
|
|
let contentHeight = NativeAttachmentMenuLayout.contentHeight(
|
|
compatibleWith: traits
|
|
)
|
|
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
|
|
|
|
XCTAssertGreaterThan(itemHeight, 52)
|
|
XCTAssertGreaterThan(contentHeight, 264)
|
|
XCTAssertEqual(
|
|
size.height,
|
|
min(contentHeight, NativeAttachmentMenuLayout.maximumHeight)
|
|
)
|
|
XCTAssertLessThanOrEqual(
|
|
size.height,
|
|
NativeAttachmentMenuLayout.maximumHeight
|
|
)
|
|
}
|
|
|
|
func testDynamicIslandQrScannerRecognizesTallSafeAreas() {
|
|
for safeAreaTopInset in [51, 59, 62] {
|
|
XCTAssertTrue(
|
|
AppDelegate.usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: CGFloat(safeAreaTopInset)
|
|
),
|
|
"\(safeAreaTopInset)"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testDynamicIslandQrScannerRejectsStandardSafeAreas() {
|
|
for safeAreaTopInset in [0, 44, 47, 50] {
|
|
XCTAssertFalse(
|
|
AppDelegate.usesDynamicIslandQrScannerPortal(
|
|
safeAreaTopInset: CGFloat(safeAreaTopInset)
|
|
),
|
|
"\(safeAreaTopInset)"
|
|
)
|
|
}
|
|
}
|
|
|
|
func testClipboardImageDataPrefersOriginalPngBytes() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let pngData = Data([0x89, 0x50, 0x4E, 0x47])
|
|
let jpegData = Data([0xFF, 0xD8, 0xFF])
|
|
pasteboard.setItems([
|
|
["public.png": pngData, "public.jpeg": jpegData]
|
|
])
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), pngData)
|
|
}
|
|
|
|
func testClipboardImageDataPreservesOriginalWebPBytesForValidation() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let webPData = Data("RIFFxxxxWEBP".utf8)
|
|
pasteboard.setData(webPData, forPasteboardType: "org.webmproject.webp")
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), webPData)
|
|
}
|
|
|
|
func testClipboardImageDataPreservesOriginalGifBytesForValidation() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
let gifData = Data("GIF89a".utf8)
|
|
pasteboard.setData(gifData, forPasteboardType: "com.compuserve.gif")
|
|
|
|
XCTAssertEqual(AppDelegate.clipboardImageData(from: pasteboard), gifData)
|
|
}
|
|
|
|
func testClipboardImageDataReturnsNilWithoutAnImage() throws {
|
|
let pasteboard = try XCTUnwrap(
|
|
UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true)
|
|
)
|
|
defer { UIPasteboard.remove(withName: pasteboard.name) }
|
|
pasteboard.string = "text only"
|
|
|
|
XCTAssertNil(AppDelegate.clipboardImageData(from: pasteboard))
|
|
}
|
|
|
|
func testSanitizePngRemovesUIKitMetadataChunks() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
|
XCTAssertEqual(
|
|
try pngChunkTypes(fixture),
|
|
[
|
|
"IHDR", "sRGB", "eXIf", "pHYs", "iDOT", "IDAT", "IDAT", "IEND",
|
|
])
|
|
|
|
let sanitized = try MediaSanitizer.scrubPng(fixture)
|
|
|
|
XCTAssertEqual(
|
|
try pngChunkTypes(sanitized),
|
|
[
|
|
"IHDR", "sRGB", "IDAT", "IDAT", "IEND",
|
|
])
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
|
|
var withTrailingPayload = fixture
|
|
withTrailingPayload.append(Data("hidden location".utf8))
|
|
let scrubbedTrailingPayload = try MediaSanitizer.scrubPng(withTrailingPayload)
|
|
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
|
}
|
|
|
|
func testSanitizePngSupportsDataSlices() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "png")
|
|
let padded = Data([0x00]) + fixture
|
|
let slice = padded.dropFirst()
|
|
XCTAssertNotEqual(slice.startIndex, 0)
|
|
|
|
let sanitized = try MediaSanitizer.scrubPng(slice)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/png")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
}
|
|
|
|
func testSanitizeJpegRemovesUIKitMetadataSegments() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
XCTAssertEqual(try jpegMetadataMarkers(fixture), [0xE0, 0xE1, 0xED])
|
|
|
|
let sanitized = try MediaSanitizer.scrubJpeg(fixture)
|
|
|
|
XCTAssertEqual(try jpegMetadataMarkers(sanitized), [0xE0])
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
|
|
var withTrailingPayload = fixture
|
|
withTrailingPayload.append(Data("hidden location".utf8))
|
|
let scrubbedTrailingPayload = try MediaSanitizer.scrubJpeg(withTrailingPayload)
|
|
XCTAssertEqual(scrubbedTrailingPayload, sanitized)
|
|
}
|
|
|
|
func testSanitizeJpegSupportsDataSlices() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
let padded = Data([0x00]) + fixture
|
|
let slice = padded.dropFirst()
|
|
XCTAssertNotEqual(slice.startIndex, 0)
|
|
|
|
let sanitized = try MediaSanitizer.scrubJpeg(slice)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: sanitized))
|
|
}
|
|
|
|
func testEncodeJpegScrubsUIKitOutput() throws {
|
|
let fixture = try fixtureData(named: "UIKitEncoded", extension: "jpg")
|
|
let image = try XCTUnwrap(UIImage(data: fixture))
|
|
|
|
let encoded = try XCTUnwrap(MediaSanitizer.encodeJpeg(image))
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(encoded, mimeType: "image/jpeg")
|
|
XCTAssertNotNil(UIImage(data: encoded))
|
|
}
|
|
|
|
func testSanitizeDisplayP3ImagePreservesRenderedColorInSRGB() throws {
|
|
let image = try displayP3Image(red: 0.9, green: 0.2, blue: 0.1)
|
|
let expectedColor = try sRGBPixel(from: image)
|
|
let mimeTypesAndAccuracy: [(mimeType: String, accuracy: UInt8)] = [
|
|
("image/png", 0), ("image/jpeg", 1),
|
|
]
|
|
|
|
for (mimeType, accuracy) in mimeTypesAndAccuracy {
|
|
let sanitized = try XCTUnwrap(
|
|
MediaSanitizer.sanitizeImage(image, mimeType: mimeType),
|
|
"Failed to sanitize Display-P3 image as \(mimeType)"
|
|
)
|
|
|
|
try assertMatchesRelayImageMetadataPolicy(sanitized, mimeType: mimeType)
|
|
let decoded = try XCTUnwrap(UIImage(data: sanitized))
|
|
XCTAssertEqual(
|
|
decoded.cgImage?.colorSpace?.name,
|
|
CGColorSpace(name: CGColorSpace.sRGB)?.name
|
|
)
|
|
let actualColor = try sRGBPixel(from: decoded)
|
|
XCTAssertEqual(actualColor.count, expectedColor.count)
|
|
for (actual, expected) in zip(actualColor, expectedColor) {
|
|
XCTAssertLessThanOrEqual(
|
|
actual > expected ? actual - expected : expected - actual,
|
|
accuracy
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() {
|
|
let order = ["people", "nature", "flags"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -320,
|
|
"nature": -12,
|
|
"flags": 200,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerFollowsScrollPastEachHeader() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
// Scrolled to the very top: the first section is highlighted.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 400, "flags": 800],
|
|
viewportTop: 0
|
|
),
|
|
"people"
|
|
)
|
|
|
|
// Scrolled far enough that Flags has reached the top.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": -800, "nature": -400, "flags": 0],
|
|
viewportTop: 0
|
|
),
|
|
"flags"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() {
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: ["people", "nature"],
|
|
offsets: ["people": 40, "nature": 400],
|
|
viewportTop: 0
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() {
|
|
// The list has overflowed (People scrolled above the top) and its end is on
|
|
// screen, but the short Custom section's header sits below the top because
|
|
// the content clamps before it can reach it. The rail must still highlight
|
|
// Custom rather than leaving Nature — its predecessor — selected.
|
|
let order = ["people", "nature", "custom"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -900,
|
|
"nature": -420,
|
|
"custom": 360,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 500
|
|
),
|
|
"custom"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() {
|
|
// The same short-final geometry, but the content end is still below the
|
|
// viewport (the user has not reached the bottom), so the ordinary
|
|
// header-at-top rule applies and Nature stays selected.
|
|
let order = ["people", "nature", "custom"]
|
|
let offsets: [String: CGFloat] = [
|
|
"people": -900,
|
|
"nature": -420,
|
|
"custom": 360,
|
|
]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 900
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerDoesNotForceLastSectionForAShortList() {
|
|
// A list that fits without scrolling has its content end on screen too, but
|
|
// its first header is still at the top — so the bottom rule must not fire
|
|
// and steal the highlight to the final section.
|
|
let order = ["people", "nature"]
|
|
let offsets: [String: CGFloat] = ["people": 0, "nature": 120]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: offsets,
|
|
viewportTop: 0,
|
|
viewportBottom: 500,
|
|
contentBottom: 240
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerDoesNotFlickerBackAtPinnedHeaderBoundary() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
// Nature has just become selected. A subsequent layout pass can briefly
|
|
// report its pinned header a couple of points below the boundary while the
|
|
// previous header is still pinned. Keep Nature selected through that
|
|
// transient frame instead of alternating the category rail.
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 2, "flags": 400],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"nature"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerReleasesBoundaryLatchOnRealUpwardScroll() {
|
|
let order = ["people", "nature", "flags"]
|
|
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: order,
|
|
offsets: ["people": 0, "nature": 24, "flags": 424],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testCategoryTrackerReleasesSelectionWhenOldHeaderIsMissing() {
|
|
XCTAssertEqual(
|
|
NativeEmojiCategoryTracker.selectedSectionID(
|
|
order: ["people", "nature", "flags"],
|
|
offsets: ["people": 0, "flags": 400],
|
|
viewportTop: 0,
|
|
currentSelection: "nature"
|
|
),
|
|
"people"
|
|
)
|
|
}
|
|
|
|
func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
|
|
let maximumConcurrentDownloads = 3
|
|
let taskCount = 8
|
|
let probe = NativeEmojiDownloadProbe()
|
|
let tasksAttemptedAdmission = XCTestExpectation(
|
|
description: "all download tasks attempted admission"
|
|
)
|
|
tasksAttemptedAdmission.expectedFulfillmentCount = taskCount
|
|
let loader = NativeEmojiRemoteImageLoader(
|
|
maximumConcurrentDownloads: maximumConcurrentDownloads,
|
|
cacheByteLimit: 0,
|
|
admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() },
|
|
downloader: { _ in
|
|
await probe.holdDownload()
|
|
return UIImage()
|
|
}
|
|
)
|
|
let tasks = (0..<taskCount).map { index in
|
|
Task {
|
|
try await loader.image(
|
|
for: URLRequest(
|
|
url: try XCTUnwrap(URL(string: "https://example.com/\(index).png"))
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
await fulfillment(of: [tasksAttemptedAdmission], timeout: 2)
|
|
await probe.waitUntilStarted(maximumConcurrentDownloads)
|
|
var snapshot = await probe.snapshot()
|
|
XCTAssertEqual(snapshot.started, maximumConcurrentDownloads)
|
|
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
|
|
|
for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count {
|
|
await probe.releaseOne()
|
|
await probe.waitUntilStarted(expectedStarted)
|
|
}
|
|
await probe.releaseAll()
|
|
for task in tasks {
|
|
_ = try await task.value
|
|
}
|
|
|
|
snapshot = await probe.snapshot()
|
|
XCTAssertEqual(snapshot.started, tasks.count)
|
|
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
|
XCTAssertEqual(snapshot.active, 0)
|
|
}
|
|
|
|
func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
|
|
let actionArguments: [[String: Any]] = [
|
|
[
|
|
"id": "reply", "title": "Reply",
|
|
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
|
],
|
|
[
|
|
"id": "copyText", "title": "Copy text",
|
|
"symbol": "doc.on.doc", "group": "utility",
|
|
],
|
|
[
|
|
"id": "delete", "title": "Delete message",
|
|
"symbol": "trash", "group": "destructive", "destructive": true,
|
|
],
|
|
]
|
|
let definitions = try actionArguments.map { arguments in
|
|
try XCTUnwrap(NativeMessageActionDefinition(arguments: arguments))
|
|
}
|
|
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.populatedGroups(actions: definitions),
|
|
[.primary, .utility, .destructive]
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.separatorCount(actions: definitions),
|
|
2
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceLayout.preferredHeight(
|
|
actions: definitions,
|
|
compatibleWith: UITraitCollection(
|
|
preferredContentSizeCategory: .large
|
|
)
|
|
),
|
|
153
|
|
)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionRowUsesUIKitTypographyAndSelection() throws {
|
|
let definition = try XCTUnwrap(
|
|
NativeMessageActionDefinition(
|
|
arguments: [
|
|
"id": "reply", "title": "Reply",
|
|
"symbol": "arrowshape.turn.up.left", "group": "primary",
|
|
]
|
|
)
|
|
)
|
|
var selected = false
|
|
let row = NativeMessageActionRowControl(
|
|
definition: definition,
|
|
foregroundColor: .label,
|
|
destructiveColor: .systemRed,
|
|
onSelected: { selected = true }
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
row.actionTitleLabel.font.fontDescriptor.object(forKey: .textStyle) as? String,
|
|
UIFont.TextStyle.body.rawValue
|
|
)
|
|
XCTAssertNotNil(row.actionImageView.image)
|
|
row.sendActions(for: .touchUpInside)
|
|
XCTAssertTrue(selected)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionRowExpandsForAccessibilityTypography() throws {
|
|
let traits = UITraitCollection(
|
|
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
|
|
)
|
|
let definition = try XCTUnwrap(
|
|
NativeMessageActionDefinition(
|
|
arguments: [
|
|
"id": "followThread", "title": "Follow thread",
|
|
"symbol": "bell", "group": "utility",
|
|
]
|
|
)
|
|
)
|
|
let row = NativeMessageActionRowControl(
|
|
definition: definition,
|
|
foregroundColor: .label,
|
|
destructiveColor: .systemRed,
|
|
compatibleWith: traits,
|
|
onSelected: {}
|
|
)
|
|
let fittingSize = row.systemLayoutSizeFitting(
|
|
CGSize(width: 288, height: UIView.layoutFittingCompressedSize.height),
|
|
withHorizontalFittingPriority: .required,
|
|
verticalFittingPriority: .fittingSizeLevel
|
|
)
|
|
row.frame = CGRect(origin: .zero, size: fittingSize)
|
|
row.layoutIfNeeded()
|
|
let labelFrame = row.convert(
|
|
row.actionTitleLabel.bounds,
|
|
from: row.actionTitleLabel
|
|
)
|
|
|
|
XCTAssertGreaterThan(fittingSize.height, 48)
|
|
XCTAssertGreaterThan(labelFrame.height, 0)
|
|
XCTAssertGreaterThanOrEqual(
|
|
labelFrame.minY,
|
|
NativeMessageActionSurfaceLayout.rowVerticalPadding
|
|
)
|
|
XCTAssertLessThanOrEqual(
|
|
labelFrame.maxY,
|
|
fittingSize.height - NativeMessageActionSurfaceLayout.rowVerticalPadding
|
|
)
|
|
XCTAssertEqual(row.actionTitleLabel.numberOfLines, 0)
|
|
XCTAssertFalse(row.actionTitleLabel.adjustsFontSizeToFitWidth)
|
|
}
|
|
|
|
@MainActor
|
|
func testNativeMessageActionSurfaceUsesSystemMaterial() {
|
|
let effect = NativeMessageActionSurfaceAppearance.backdropEffect(
|
|
reduceTransparency: false
|
|
)
|
|
if #available(iOS 26.0, *) {
|
|
XCTAssertTrue(effect is UIGlassEffect)
|
|
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 33)
|
|
} else {
|
|
XCTAssertTrue(effect is UIBlurEffect)
|
|
XCTAssertEqual(NativeMessageActionSurfaceLayout.cornerRadius, 12)
|
|
}
|
|
XCTAssertNil(
|
|
NativeMessageActionSurfaceAppearance.backdropEffect(
|
|
reduceTransparency: true
|
|
)
|
|
)
|
|
}
|
|
|
|
func testNativeMessageActionListDoesNotHideDialogSiblings() {
|
|
XCTAssertFalse(
|
|
NativeMessageActionSurfaceAppearance.actionListAccessibilityViewIsModal
|
|
)
|
|
}
|
|
|
|
func testNativeMessageActionSurfaceMatchesFlutterInterfaceStyle() {
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "dark"),
|
|
.dark
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "light"),
|
|
.light
|
|
)
|
|
XCTAssertEqual(
|
|
NativeMessageActionSurfaceAppearance.interfaceStyle(from: "system"),
|
|
.unspecified
|
|
)
|
|
}
|
|
|
|
private func displayP3Image(red: CGFloat, green: CGFloat, blue: CGFloat) throws -> UIImage {
|
|
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.displayP3))
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
|
let context = try XCTUnwrap(
|
|
CGContext(
|
|
data: nil,
|
|
width: 1,
|
|
height: 1,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 4,
|
|
space: colorSpace,
|
|
bitmapInfo: bitmapInfo.rawValue
|
|
)
|
|
)
|
|
context.setFillColor(
|
|
try XCTUnwrap(CGColor(colorSpace: colorSpace, components: [red, green, blue, 1]))
|
|
)
|
|
context.fill(CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
return UIImage(cgImage: try XCTUnwrap(context.makeImage()))
|
|
}
|
|
|
|
private func sRGBPixel(from image: UIImage) throws -> [UInt8] {
|
|
let colorSpace = try XCTUnwrap(CGColorSpace(name: CGColorSpace.sRGB))
|
|
var bytes = [UInt8](repeating: 0, count: 4)
|
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
|
let context = try bytes.withUnsafeMutableBytes { bytes in
|
|
try XCTUnwrap(
|
|
CGContext(
|
|
data: bytes.baseAddress,
|
|
width: 1,
|
|
height: 1,
|
|
bitsPerComponent: 8,
|
|
bytesPerRow: 4,
|
|
space: colorSpace,
|
|
bitmapInfo: bitmapInfo.rawValue
|
|
)
|
|
)
|
|
}
|
|
context.interpolationQuality = .none
|
|
context.draw(try XCTUnwrap(image.cgImage), in: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
return bytes
|
|
}
|
|
|
|
private func fixtureData(named name: String, extension fileExtension: String) throws -> Data {
|
|
let url = try XCTUnwrap(
|
|
Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension))
|
|
return try Data(contentsOf: url)
|
|
}
|
|
}
|
|
|
|
private final class KeyboardDismissalSpyWindow: UIWindow {
|
|
private(set) var didForceEndEditing = false
|
|
|
|
override func endEditing(_ force: Bool) -> Bool {
|
|
didForceEndEditing = force
|
|
return true
|
|
}
|
|
}
|
|
|
|
private enum RelayImagePolicyError: Error {
|
|
case invalidPng
|
|
case invalidJpeg
|
|
case metadataForbidden
|
|
}
|
|
|
|
private let pngSignature = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
|
private let allowedPngAncillaryChunks: Set<String> = [
|
|
"cHRM", "gAMA", "sBIT", "sRGB", "bKGD", "hIST", "tRNS", "sPLT", "acTL", "fcTL", "fdAT",
|
|
]
|
|
|
|
private func assertMatchesRelayImageMetadataPolicy(_ data: Data, mimeType: String) throws {
|
|
switch mimeType {
|
|
case "image/png":
|
|
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
var offset = pngSignature.count
|
|
while offset < data.count {
|
|
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
|
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
|
guard payloadLength <= data.count - offset - 12 else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
let typeBytes = data[(offset + 4)..<(offset + 8)]
|
|
guard let type = String(bytes: typeBytes, encoding: .ascii) else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
let chunkEnd = offset + payloadLength + 12
|
|
let isAncillary = typeBytes[typeBytes.startIndex] & 0x20 != 0
|
|
if isAncillary, !allowedPngAncillaryChunks.contains(type) {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
offset = chunkEnd
|
|
if type == "IEND" {
|
|
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
|
return
|
|
}
|
|
}
|
|
throw RelayImagePolicyError.invalidPng
|
|
case "image/jpeg":
|
|
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
var offset = 2
|
|
var inScan = false
|
|
while offset < data.count {
|
|
if inScan, data[offset] != 0xFF {
|
|
offset += 1
|
|
continue
|
|
}
|
|
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
|
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
|
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
|
let marker = data[offset]
|
|
offset += 1
|
|
if inScan, marker == 0x00 { continue }
|
|
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
|
if marker == 0xD9 {
|
|
guard offset == data.count else { throw RelayImagePolicyError.metadataForbidden }
|
|
return
|
|
}
|
|
guard marker != 0xD8, data.count - offset >= 2 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let length = Int(try readUInt16BigEndian(data, at: offset))
|
|
guard length >= 2, length <= data.count - offset else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let payload = (offset + 2)..<(offset + length)
|
|
if marker == 0xE0 {
|
|
guard
|
|
payload.count >= 14,
|
|
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
|
0x4A, 0x46, 0x49, 0x46, 0x00,
|
|
]),
|
|
payload.count
|
|
== 14 + 3 * Int(data[payload.lowerBound + 12]) * Int(data[payload.lowerBound + 13])
|
|
else {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
} else if marker == 0xEE {
|
|
guard
|
|
payload.count == 12,
|
|
data[payload.lowerBound..<(payload.lowerBound + 5)].elementsEqual([
|
|
0x41, 0x64, 0x6F, 0x62, 0x65,
|
|
])
|
|
else {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
} else if (0xE1...0xED).contains(marker) || marker == 0xEF || marker == 0xFE {
|
|
throw RelayImagePolicyError.metadataForbidden
|
|
}
|
|
offset += length
|
|
inScan = marker == 0xDA
|
|
}
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
default:
|
|
XCTFail("Unsupported test MIME type: \(mimeType)")
|
|
}
|
|
}
|
|
|
|
private func pngChunkTypes(_ data: Data) throws -> [String] {
|
|
guard data.count >= pngSignature.count, data.prefix(pngSignature.count) == pngSignature else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
var result: [String] = []
|
|
var offset = pngSignature.count
|
|
while offset < data.count {
|
|
guard data.count - offset >= 12 else { throw RelayImagePolicyError.invalidPng }
|
|
let payloadLength = Int(try readUInt32BigEndian(data, at: offset))
|
|
guard payloadLength <= data.count - offset - 12 else { throw RelayImagePolicyError.invalidPng }
|
|
guard let type = String(bytes: data[(offset + 4)..<(offset + 8)], encoding: .ascii) else {
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
result.append(type)
|
|
offset += payloadLength + 12
|
|
if type == "IEND" { return result }
|
|
}
|
|
throw RelayImagePolicyError.invalidPng
|
|
}
|
|
|
|
private func jpegMetadataMarkers(_ data: Data) throws -> [UInt8] {
|
|
guard data.count >= 2, data[0] == 0xFF, data[1] == 0xD8 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
var result: [UInt8] = []
|
|
var offset = 2
|
|
var inScan = false
|
|
while offset < data.count {
|
|
if inScan, data[offset] != 0xFF {
|
|
offset += 1
|
|
continue
|
|
}
|
|
guard data[offset] == 0xFF else { throw RelayImagePolicyError.invalidJpeg }
|
|
while offset < data.count, data[offset] == 0xFF { offset += 1 }
|
|
guard offset < data.count else { throw RelayImagePolicyError.invalidJpeg }
|
|
let marker = data[offset]
|
|
offset += 1
|
|
if inScan, marker == 0x00 { continue }
|
|
if (0xD0...0xD7).contains(marker) || marker == 0x01 { continue }
|
|
if marker == 0xD9 { return result }
|
|
guard marker != 0xD8, data.count - offset >= 2 else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
let length = Int(try readUInt16BigEndian(data, at: offset))
|
|
guard length >= 2, length <= data.count - offset else {
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
if (0xE0...0xEF).contains(marker) || marker == 0xFE {
|
|
result.append(marker)
|
|
}
|
|
offset += length
|
|
inScan = marker == 0xDA
|
|
}
|
|
throw RelayImagePolicyError.invalidJpeg
|
|
}
|
|
|
|
private func readUInt16BigEndian(_ data: Data, at offset: Int) throws -> UInt16 {
|
|
guard data.count - offset >= 2 else { throw RelayImagePolicyError.invalidJpeg }
|
|
return UInt16(data[offset]) << 8 | UInt16(data[offset + 1])
|
|
}
|
|
|
|
private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32 {
|
|
guard data.count - offset >= 4 else { throw RelayImagePolicyError.invalidPng }
|
|
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
|
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
|
}
|
|
|
|
private actor NativeEmojiDownloadProbe {
|
|
private struct MilestoneWaiter {
|
|
let count: Int
|
|
let continuation: CheckedContinuation<Void, Never>
|
|
}
|
|
|
|
private var active = 0
|
|
private var peakActive = 0
|
|
private var started = 0
|
|
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
|
private var milestoneWaiters: [MilestoneWaiter] = []
|
|
|
|
func holdDownload() async {
|
|
active += 1
|
|
started += 1
|
|
peakActive = max(peakActive, active)
|
|
resumeReachedMilestones()
|
|
await withCheckedContinuation { continuation in
|
|
releaseContinuations.append(continuation)
|
|
}
|
|
active -= 1
|
|
}
|
|
|
|
func waitUntilStarted(_ count: Int) async {
|
|
guard started < count else { return }
|
|
await withCheckedContinuation { continuation in
|
|
milestoneWaiters.append(
|
|
MilestoneWaiter(count: count, continuation: continuation)
|
|
)
|
|
}
|
|
}
|
|
|
|
func releaseOne() {
|
|
guard !releaseContinuations.isEmpty else { return }
|
|
releaseContinuations.removeFirst().resume()
|
|
}
|
|
|
|
func releaseAll() {
|
|
let continuations = releaseContinuations
|
|
releaseContinuations.removeAll()
|
|
for continuation in continuations {
|
|
continuation.resume()
|
|
}
|
|
}
|
|
|
|
func snapshot() -> (active: Int, peakActive: Int, started: Int) {
|
|
(active, peakActive, started)
|
|
}
|
|
|
|
private func resumeReachedMilestones() {
|
|
let reached = milestoneWaiters.filter { $0.count <= started }
|
|
milestoneWaiters.removeAll { $0.count <= started }
|
|
for waiter in reached {
|
|
waiter.continuation.resume()
|
|
}
|
|
}
|
|
}
|