feat(apple): collect transfers held for this device

Adds the opt-in foreground check and an explicit Check now, the waiting-to-
be-delivered list on the sender side, and honest reporting when a send could
not be delivered: a closed app is a delay, not a success nobody received.

The setting is off by default and its footer states that checking reveals
app-open times to remembered devices, since that is the reason it is a
setting at all.

Records in the design doc that this shipped as one global toggle rather than
the per-contact opt-in originally specified.
This commit is contained in:
2026-08-06 19:00:04 +02:00
parent 94a8ba2103
commit 0f70663263
52 changed files with 492 additions and 21 deletions

View File

@@ -235,7 +235,13 @@ final class ContactsModelTests: XCTestCase {
fileSystemService: files
)
gateway.sendToContactResult = .success(
Share(transferId: 1, ticket: "vnd1:x", transferName: "doc", contentHash: "h", fileCount: 1, totalSize: 2)
ContactSendOutcome(
share: Share(
transferId: 1, ticket: "vnd1:x", transferName: "doc",
contentHash: "h", fileCount: 1, totalSize: 2
),
delivered: true
)
)
model.chooseFilesToSend(to: "peer")
@@ -276,6 +282,98 @@ final class ContactsModelTests: XCTestCase {
XCTAssertTrue(gateway.sentToContacts.isEmpty)
}
/// Polling is opt-in: it tells every contact the app was opened.
func testForegroundCheckIsSkippedUnlessEnabled() async {
let gateway = FakeCoreGateway()
let (model, _) = makeModel(gateway)
await model.checkForOffersOnForeground()
XCTAssertEqual(gateway.pollCount, 0)
}
func testForegroundCheckRunsOnceEnabled() async {
let gateway = FakeCoreGateway()
let (model, preferences) = makeModel(gateway)
model.setCheckForOffersOnOpen(true)
await model.checkForOffersOnForeground()
XCTAssertEqual(gateway.pollCount, 1)
XCTAssertTrue(preferences.preferences.checkForOffersOnOpen)
}
/// The explicit "check now" ignores the setting: the user just asked.
func testExplicitCheckRunsEvenWhenTheSettingIsOff() async {
let gateway = FakeCoreGateway()
gateway.pollResult = .success(2)
let (model, _) = makeModel(gateway)
let collected = await model.collectWaitingOffers()
XCTAssertEqual(collected, 2)
XCTAssertEqual(gateway.pollCount, 1)
}
/// A transfer that could not be delivered is reported as waiting, not as a
/// success nobody has received.
func testAnUndeliveredSendIsReportedAsWaiting() async {
let gateway = FakeCoreGateway()
let files = FakeFileSystemService()
let defaults = UserDefaults(suiteName: "contacts-held-\(UUID().uuidString)")!
let preferences = AppPreferencesRepository(
defaults: defaults,
fallback: AppPreferencesDefaults(
username: "tester",
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
themeMode: .system
)
)
let messages = UiMessageController()
let model = ContactsModel(
repository: gateway,
messages: messages,
preferences: preferences,
fileSystemService: files
)
gateway.sendToContactResult = .success(
ContactSendOutcome(
share: Share(
transferId: 1, ticket: "vnd1:x", transferName: "doc",
contentHash: "h", fileCount: 1, totalSize: 2
),
delivered: false
)
)
model.chooseFilesToSend(to: "peer")
await model.onFilesPicked([
PickedShareFile(value: "/tmp/doc.txt", displayName: "doc.txt", isDirectory: false)
])
XCTAssertEqual(messages.current?.tone, .info)
}
func testHeldOffersAreLoadedForDisplay() async {
let gateway = FakeCoreGateway()
gateway.heldOffersResult = .success([
HeldOfferModel(
offerId: "held-1",
endpointId: "peer",
transferId: 1,
transferName: "doc",
fileCount: 1,
totalBytes: 2,
createdAt: 0
)
])
let (model, _) = makeModel(gateway)
await model.refresh()
XCTAssertEqual(model.state.heldOffers.map(\.offerId), ["held-1"])
}
func testUnreachableContactIsSurfacedForRepairing() async {
let gateway = FakeCoreGateway()
gateway.contactsResult = .success([contact("peer", canSend: false)])

View File

@@ -90,7 +90,10 @@ final class FakeCoreGateway: CoreGateway {
var respondToPairingResult: Result<Bool, Error> = .success(true)
/// Ticket handed back when an offer is accepted; nil models a declined one.
var offerTicket: String? = "vnd1:offered"
var sendToContactResult: Result<Share, Error> = .failure(TestError.unimplemented)
var sendToContactResult: Result<ContactSendOutcome, Error> = .failure(TestError.unimplemented)
var heldOffersResult: Result<[HeldOfferModel], Error> = .success([])
var pollResult: Result<UInt64, Error> = .success(0)
private(set) var pollCount = 0
var forgetContactResult: Result<Void, Error> = .success(())
var blockedResult: Result<[String], Error> = .success([])
@@ -129,10 +132,15 @@ final class FakeCoreGateway: CoreGateway {
sources: [ShareSource],
transferName: String,
senderName: String
) async -> Result<Share, Error> {
) async -> Result<ContactSendOutcome, Error> {
sentToContacts.append(endpointId)
return sendToContactResult
}
func heldOffers() async -> Result<[HeldOfferModel], Error> { heldOffersResult }
func pollContactsForOffers() async -> Result<UInt64, Error> {
pollCount += 1
return pollResult
}
func forgetContact(endpointId: String) async -> Result<Void, Error> {
forgottenContacts.append(endpointId)
return forgetContactResult
@@ -168,13 +176,14 @@ final class FakeFileSystemService: FileSystemService {
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
private(set) var shareDestinations: [ShareDestination] = []
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<ContactSendOutcome, Error> {
shareDestinations.append(destination)
switch destination {
case .invitation(let accessPolicy):
return await repository.shareSources(
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId):
return await repository.sendToContact(
endpointId: endpointId, sources: [], transferName: transferName, senderName: senderName