Files
buzz/mobile/ios/RunnerTests/RunnerTests.swift
Tom Brow c432a111ca feat(mobile): push notifications MVP (#6269)
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>
2026-08-28 15:15:09 -06:00

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()
}
}
}