Files
buzz/mobile/ios/RunnerTests/BuzzCommunicationNotificationTests.swift
Tom Brow 2b428f56b8 feat(mobile): share into Buzz from the iOS share sheet (#9433)
Lets people share screenshots, screen recordings, photos, URLs and text
from any iOS app into Buzz. Today Buzz doesn't appear in the share sheet
at all.

- **Plain Buzz icon:** opens a new Solo chat with the items in its
composer.
- **Suggested conversation:** iOS offers channels and DMs the person
recently sent top-level messages in. Choosing one opens that
conversation with the items in its composer. When the conversation is
already open, the share goes to that page's composer instead of opening
a second one; if another page covers it, Buzz stays where it is and
shows "Go back to that conversation to finish sharing", and the items
join the draft when the person goes back.
- The share extension has no UI and sends nothing. It copies the items
into the app group and opens Buzz, which prefills the normal composer,
so drafts, attachments, mentions and failed-send handling all stay the
app's (see [the
ADR](https://github.com/block/buzz/blob/mobile-share-extension-design/docs/adr/0003-share-hands-off-to-the-conversation.md)).
- If the conversation already has a draft, Buzz asks whether to add the
shared items to it or replace it.
- A share to a conversation that's no longer available (a removed
community or channel, or an unreadable share) shows "That conversation
isn't available". A share while signed out shows "Sign in to share".

Suggestions come from an outgoing `INSendMessageIntent` the app donates
after a top-level send or a new Solo chat. The notification extension no
longer donates incoming messages, so receiving a message never makes a
conversation a suggestion; it still turns the push into a communication
notification with the sender's avatar. Both use the same conversation
identifier, now the plain community and channel IDs instead of a hash,
because the extension needs to hand the identifier back to the app.

| Before | After |
| --- | --- |
| <img alt="Share sheet without Buzz"
src="https://github.com/user-attachments/assets/452fac1b-7e87-44c3-a442-099f78d0e07b"
width="300"> | <img alt="Share sheet with Buzz"
src="https://github.com/user-attachments/assets/54b9775d-29b4-49a5-8ab9-5fda8718a02f"
width="300"> |

| Buzz icon: new Solo chat | Suggested DM with a draft | After Add |
| --- | --- | --- |
| <img alt="New Solo chat with the shared photo"
src="https://github.com/user-attachments/assets/c5da4d8b-1dd8-4e30-9e92-9dba515b3623"
width="250"> | <img alt="Add to your draft prompt"
src="https://github.com/user-attachments/assets/2f879f8a-3b9f-48e0-b963-2828e90dc9fe"
width="250"> | <img alt="Draft with the shared link and photo added"
src="https://github.com/user-attachments/assets/12832006-85c5-4447-98b5-62932552c931"
width="250"> |


https://github.com/user-attachments/assets/b9d380fa-e802-4ed7-89a8-592a03784c8f

### Regressions

- Nothing changes for people who never use the share sheet, except that
sending a top-level message or starting a Solo chat now makes one local
`INInteraction.donate` call (skipped while the age restriction is on,
which still purges every interaction).
- Communication notifications look the same. Dropping the incoming
donation removes work (and a deletion fence) from the notification
extension's time budget.
- The share hand-off reuses the existing deep link dispatcher. A share
link is a new link type; notification and message links take the same
paths as before.

### Validation

- Added tests:
- Dispatcher hand-off: a share to a suggested channel opens it with the
shared text and files; shares to a removed community, an unlisted
channel, an unknown manifest version or a missing manifest show the
error and delete the pending share; share links accept only the
extension's UUID ids.
- Composer delivery: with an existing draft, Add appends the shared text
on a new line and attaches the file, Replace clears the draft first, and
the app group copy is deleted either way.
- Notification presenter: specializes the notification from the intent,
or falls back to the ordinary content when that throws (replaces the
donation and deletion-fence tests).
- Full mobile Flutter suite and BuzzPushKit Swift tests pass.
- Automated app run on an iPhone 17 Pro simulator (iOS 27, dev relay
test community), recorded above: sent a DM, shared a photo from Photos
with the Buzz icon from a cold start (new Solo chat with the photo
attached), then handed a link and photo to a DM that had a draft and
chose Add. Also checked that a share's pending files are deleted after
delivery.
- Limitation: the iOS simulator never shows share-sheet conversation
suggestions (it has no `coreduetd`), so the suggested-DM step in the
video is staged by writing the same pending share and link the extension
writes. Choosing a real suggestion still needs a check on a device.
- Known gaps, all confined to sharing: suggestions have no avatar image
yet; a suggestion for a conversation left after signing out shows "isn't
available"; choosing Later in the unfinished-chat dialog keeps the share
until the next new chat; and a pending share the app never receives (for
example when Buzz doesn't open) stays in the app group.

### Before merge

- [x] On a real iPhone, choose a suggested conversation in the share
sheet and confirm the shared items open in that conversation's composer
(the simulator can't show suggestions). Waived before merge: the managed
test iPhone shows no share-sheet suggestions for any app, so this will
be checked on a personal phone after landing.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
2026-11-12 22:57:21 -08:00

450 lines
17 KiB
Swift

import BuzzPushKit
import Flutter
import Intents
import UserNotifications
import XCTest
@testable import Buzz
final class BuzzCommunicationNotificationTests: XCTestCase {
func testIntentUsesVerifiedSenderAvatarAndGroupName() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: "General",
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertEqual(intent.sender?.customIdentifier, descriptor.senderIdentifier)
XCTAssertNotNil(intent.sender?.image)
XCTAssertNotNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertEqual(intent.content, "Hello Buzz")
XCTAssertEqual(intent.speakableGroupName?.spokenPhrase, "General")
XCTAssertEqual(intent.conversationIdentifier, resolution.conversationIdentifier)
XCTAssertNil(intent.recipients)
XCTAssertEqual(descriptor.recipientCount, 1)
XCTAssertEqual(
(intent.donationMetadata as? INSendMessageIntentDonationMetadata)?.recipientCount,
1
)
}
func testDirectMessageRetainsSenderAvatarWithoutGroupMetadata() throws {
let resolution = communicationResolution(
displayName: "Alice",
groupName: nil,
avatarPNG: Data([0x89, 0x50, 0x4E, 0x47])
)
let descriptor = try XCTUnwrap(
BuzzCommunicationNotificationDescriptor(resolution: resolution)
)
let intent = BuzzCommunicationNotificationPresenter.makeIntent(descriptor)
XCTAssertEqual(intent.sender?.displayName, "Alice")
XCTAssertNotNil(intent.sender?.image)
XCTAssertNil(intent.image(forParameterNamed: \.speakableGroupName))
XCTAssertNil(intent.speakableGroupName)
XCTAssertNil(intent.donationMetadata)
}
func testMissingVerifiedRecipientCountUsesOrdinaryPresentation() {
XCTAssertNil(
BuzzCommunicationNotificationDescriptor(
resolution: communicationResolution(recipientCount: nil)
)
)
}
func testPresentationSpecializesOrFallsBackToOrdinaryContent() {
let ordinary = UNMutableNotificationContent()
ordinary.title = "Alice"
ordinary.body = "Hello Buzz"
let specialized = BuzzCommunicationNotificationPresenter(updateContent: { _, _ in
let content = UNMutableNotificationContent()
content.title = "specialized"
return content
}).present(ordinaryContent: ordinary, resolution: communicationResolution())
XCTAssertEqual(specialized.title, "specialized")
let failed = BuzzCommunicationNotificationPresenter(updateContent: { _, _ in
throw NSError(domain: "test", code: 2)
}).present(ordinaryContent: ordinary, resolution: communicationResolution())
XCTAssertEqual(failed.title, "Alice")
XCTAssertEqual(failed.body, "Hello Buzz")
}
private func communicationResolution(
displayName: String = "Alice",
groupName: String? = "General",
avatarPNG: Data? = nil,
recipientCount: Int? = 1
) -> BuzzPushResolution {
let communityID = "community-id"
let channelID = "channel/general:v5"
return BuzzPushResolution(
title: displayName,
body: "Hello Buzz",
subtitle: "Community",
threadIdentifier: BuzzPushPresentationIdentity.notificationThread(
communityID: communityID,
channelID: channelID
),
navigationTarget: BuzzPushNavigationTarget(
eventID: "message-id",
communityID: communityID,
channelID: channelID
),
senderPubkey: String(repeating: "a", count: 64),
senderAvatarPNG: avatarPNG,
conversationIdentifier: BuzzPushPresentationIdentity.conversation(
communityID: communityID,
channelID: channelID
),
conversationDisplayName: groupName,
conversationRecipientCount: recipientCount
)
}
}
/// Callbacks only record what they receive and assertions run after the wait,
/// so a callback that lands after its test returned cannot fail a later test.
/// The timeout is a cap; a passing test returns as soon as its callback runs.
final class BuzzPushSnapshotEnrichmentTests: XCTestCase {
private let callbackTimeout: TimeInterval = 10
/// Waits for a bridge callback. On timeout the failure also reports how long
/// one fresh user-initiated-queue-then-main-queue hop takes right then (the
/// path every bridge reply follows), capped at 2 s. A starved test host reads
/// "a fresh queue->main hop did not finish within 2 s" or hundreds of ms; a
/// callback that never fired reads "took 0.4 ms". The hop is measured after
/// the 10 s window, so a fast hop does not rule out starvation that ended at
/// the deadline. The 2026-11-01 CI failures predate this measurement; their
/// timings fit starvation, since the RunnerTests host is the full Flutter app
/// and its launch saturated the 3-vCPU runner mid-class. A passing wait
/// returns as soon as the callback runs and measures nothing.
private func waitForCallback(
_ expectation: XCTestExpectation, file: StaticString = #filePath, line: UInt = #line
) {
guard XCTWaiter().wait(for: [expectation], timeout: callbackTimeout) != .completed else { return }
let hop = XCTestExpectation(description: "queue to main hop")
let started = Date()
DispatchQueue(label: "xyz.block.buzz.test-hop", qos: .userInitiated).async {
DispatchQueue.main.async { hop.fulfill() }
}
let hopResult = XCTWaiter().wait(for: [hop], timeout: 2)
let elapsed = String(format: "%.1f ms", Date().timeIntervalSince(started) * 1000)
let hopReport = hopResult == .completed ? "took \(elapsed)" : "did not finish within 2 s"
XCTFail(
"'\(expectation.expectationDescription)' not fulfilled within \(Int(callbackTimeout)) s;"
+ " a fresh queue->main hop \(hopReport)",
file: file, line: line
)
}
func testAgeSignalRequestDoesNotDependOnNotificationStorage() {
let delegate = MissingAppGroupDelegate()
var completed = false
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: nil
) { value in
if #available(iOS 26.0, *) {
XCTAssertEqual((value as? FlutterError)?.code, "age_signal_unavailable")
} else {
XCTAssertEqual((value as? [String: Any])?["status"] as? String, "noSignal")
}
completed = true
}
XCTAssertTrue(completed)
}
@MainActor
func testNativeAgeRequestDeliversKnownMinorAndFailureThroughFlutterCallback() async {
guard #available(iOS 26.0, *) else { return }
let delegate = MissingAppGroupDelegate()
delegate.requestPlatformAgeSignal = { _ in
BuzzAgeSignalPayload.sharing(exclusiveUpperBound: 18, lowerBound: 13)
}
let minor = expectation(description: "native minor response")
var minorValue: Any?
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: UIViewController()
) { value in
minorValue = value
minor.fulfill()
}
await fulfillment(of: [minor], timeout: callbackTimeout)
XCTAssertEqual((minorValue as? [String: Any])?["status"] as? String, "signal")
XCTAssertEqual((minorValue as? [String: Any])?["ageUpper"] as? Int, 17)
delegate.requestPlatformAgeSignal = { _ in
throw NSError(domain: "InjectedAgeFailure", code: 1)
}
let failed = expectation(description: "native error response")
var failedValue: Any?
delegate.handleAgeSignalMethodCall(
FlutterMethodCall(methodName: "requestAgeSignal", arguments: nil),
viewController: UIViewController()
) { value in
failedValue = value
failed.fulfill()
}
await fulfillment(of: [failed], timeout: callbackTimeout)
XCTAssertEqual((failedValue as? FlutterError)?.code, "age_signal_unavailable")
}
func testAllowedNotificationRestoreNeedsNoAppGroupStore() {
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil
)
let completed = expectation(description: "allowed without storage")
var received: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
received = value
completed.fulfill()
})
waitForCallback(completed)
XCTAssertNil(received)
}
func testRestrictionCleanupFailurePreservesSnapshotAndRestoreReleasesAuthority() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let store = BuzzPushPresentationCacheStore(containerURL: directory)
try store.replaceCommunities([
PushLeaseCommunity(id: "retained", name: "Retained", relayUrl: "https://relay.example",
pubkey: nil, policies: [])
])
let snapshotURL = directory.appendingPathComponent(BuzzPushPresentationCacheStore.fileName)
let original = try Data(contentsOf: snapshotURL)
var deletionCalls = 0
var deletionQoS: [qos_class_t] = []
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil,
containerURL: { directory },
interactionDeletionDeadline: BuzzInteractionDeletionDeadline(
timeout: 5,
deleteAllInteractions: { completion in
deletionCalls += 1
deletionQoS.append(qos_class_self())
completion(deletionCalls == 1 ? NSError(domain: "InjectedCleanupFailure", code: 1) : nil)
},
scheduleTimeout: { _, _ in }
)
)
let restricted = expectation(description: "restriction despite cleanup failure")
var purgeResult: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "purgeAgeRestrictedNotifications", arguments: nil)
) { value in
purgeResult = value
restricted.fulfill()
})
waitForCallback(restricted)
XCTAssertEqual((purgeResult as? FlutterError)?.code, "age_restriction_purge_failed")
XCTAssertTrue(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
let restored = expectation(description: "allowed without restoring snapshot")
var restoreResult: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
restoreResult = value
restored.fulfill()
})
waitForCallback(restored)
XCTAssertNil(restoreResult)
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
XCTAssertEqual(deletionCalls, 2)
// Flutter awaits these replies; a lower QoS left them starved on busy hosts.
for qos in deletionQoS {
XCTAssertEqual(
qos, QOS_CLASS_USER_INITIATED,
"deletion ran at QoS class \(qos.rawValue), expected user-initiated (\(QOS_CLASS_USER_INITIATED.rawValue))"
)
}
XCTAssertEqual(try Data(contentsOf: snapshotURL), original)
}
func testNewBridgeRetriesRecordedCleanupWithoutRestrictingAccess() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let failure = NSError(domain: "InjectedCleanupFailure", code: 1)
let deletion = BuzzInteractionDeletionDeadline(timeout: 5,
deleteAllInteractions: { $0(failure) }, scheduleTimeout: { _, _ in })
var seedError: Error?
BuzzInteractionCleanupRetry(containerURL: directory, deletion: deletion).request {
seedError = $0
}
XCTAssertNotNil(seedError)
let bridge = BuzzPushSnapshotBridge(
appGroupIdentifier: nil,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore(accessGroup: nil),
keychainAccessGroup: nil, containerURL: { directory },
interactionDeletionDeadline: deletion
)
let completed = expectation(description: "retry failure stays allowed")
var received: Any?
XCTAssertTrue(bridge.handle(
FlutterMethodCall(methodName: "restoreAgeRestrictedNotifications", arguments: nil)
) { value in
received = value
completed.fulfill()
})
waitForCallback(completed)
XCTAssertEqual((received as? FlutterError)?.code, "interaction_cleanup_retry_failed")
XCTAssertFalse(BuzzAgeRestrictionSession.isRestricted(containerURL: directory))
}
func testMetadataAuthorityUsesNewestGrantForMatchingRelay() {
let correctRelay = grant(
generation: 2,
metadataPubkey: String(repeating: "a", count: 64)
)
let wrongRelay = grant(
relayOrigin: "https://other.example",
generation: 99,
metadataPubkey: String(repeating: "b", count: 64)
)
XCTAssertEqual(
BuzzPushSnapshotBridge.relayMetadataPubkey(
relayURL: "wss://relay.example/",
grants: [wrongRelay, correctRelay, grant(
generation: 1, metadataPubkey: String(repeating: "d", count: 64)
)]
),
correctRelay.relayMetadataPubkey
)
}
private func grant(
relayOrigin: String = "https://relay.example",
generation: Int64,
metadataPubkey: String
) -> BuzzPushEndpointGrantRecord {
BuzzPushEndpointGrantRecord(
gatewayOrigin: "https://push.example",
relayOrigin: relayOrigin,
relayPubkey: String(repeating: "c", count: 64),
relayMetadataPubkey: metadataPubkey,
appAttestKeyId: Data(repeating: 0xAA, count: 32).base64EncodedString(),
installationId: "installation",
endpointGrant: "opaque-grant",
endpointHash: String(repeating: "d", count: 64),
endpointEpoch: 1,
generation: generation,
expiresAt: 1_900_000_000
)
}
}
final class BuzzPushNotificationResponseTests: XCTestCase {
func testValidDefaultActionRoutesAndCompletesExactlyOnce() {
let target = BuzzPushNavigationTarget(
eventID: "message-id",
communityID: "community-id",
channelID: "opaque-channel-id"
)
var routedTargets: [BuzzPushNavigationTarget] = []
var forwarded = 0
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: target.userInfoValue],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion in
forwarded += 1
pluginCompletion()
pluginCompletion()
},
completion: { completions += 1 }
)
XCTAssertEqual(routedTargets, [target])
XCTAssertEqual(forwarded, 1)
XCTAssertEqual(completions, 1)
}
func testMalformedTargetFallsBackToOneCompletion() {
var routedTargets: [BuzzPushNavigationTarget] = []
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: UNNotificationDefaultActionIdentifier,
userInfo: [BuzzPushNavigationTarget.userInfoKey: ["event_id": ""]],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { _ in },
completion: { completions += 1 }
)
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
func testNonDefaultActionIgnoresLateDuplicatePluginCompletion() throws {
var routedTargets: [BuzzPushNavigationTarget] = []
var pluginCompletion: (() -> Void)?
var completions = 0
BuzzPushNotificationResponseCoordinator.handle(
actionIdentifier: "buzz.reply",
userInfo: [:],
onTarget: { routedTargets.append($0) },
forwardToFlutter: { pluginCompletion = $0 },
completion: { completions += 1 }
)
let capturedCompletion = try XCTUnwrap(pluginCompletion)
capturedCompletion()
capturedCompletion()
XCTAssertTrue(routedTargets.isEmpty)
XCTAssertEqual(completions, 1)
}
}
private final class MissingAppGroupDelegate: AppDelegate {
override var appGroupIdentifier: String? { nil }
}
final class BuzzPushNavigationBridgeTests: XCTestCase {
func testWarmAndColdNavigationCarryTheSameThreadArguments() throws {
let target = BuzzPushNavigationTarget(
eventID: String(repeating: "a", count: 64), communityID: "community",
channelID: "channel", threadRootID: String(repeating: "b", count: 64)
)
let expected = ["eventId": target.eventID, "communityId": "community",
"channelId": "channel", "threadRootId": String(repeating: "b", count: 64)]
XCTAssertEqual(target.flutterArguments, expected)
let buffer = BuzzPushNavigationBuffer()
buffer.record(target)
XCTAssertEqual(try XCTUnwrap(buffer.take()).flutterArguments, expected)
XCTAssertNil(buffer.take())
}
func testChannelNavigationOmitsOptionalThreadArgument() {
let target = BuzzPushNavigationTarget(
eventID: "event", communityID: "community", channelID: "channel"
)
XCTAssertEqual(target.flutterArguments,
["eventId": "event", "communityId": "community", "channelId": "channel"])
}
}