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

@@ -93,6 +93,9 @@ struct RootView: View {
// unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() }
// Opt-in and foreground-only: collecting transfers held for this
// device also tells every contact that the app was opened.
Task { await graph.contactsModel.checkForOffersOnForeground() }
case .background:
graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active

View File

@@ -127,6 +127,9 @@ struct AppPreferences: Equatable {
/// Devices the user declined to remember. Persisted so a repeat transfer
/// with the same device does not re-ask forever.
var declinedPairingSuggestions: Set<String>
/// Whether opening the app asks remembered devices for waiting transfers.
/// Off by default: it reveals app-open times to every contact.
var checkForOffersOnOpen: Bool
}
struct AppPreferencesDefaults {
@@ -152,6 +155,7 @@ final class AppPreferencesRepository: ObservableObject {
static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime"
static let declinedPairingSuggestions = "declined_pairing_suggestions"
static let checkForOffersOnOpen = "check_for_offers_on_open"
}
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -175,7 +179,8 @@ final class AppPreferencesRepository: ObservableObject {
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime,
declinedPairingSuggestions: declined
declinedPairingSuggestions: declined,
checkForOffersOnOpen: defaults.bool(forKey: Key.checkForOffersOnOpen)
)
}
@@ -237,6 +242,11 @@ final class AppPreferencesRepository: ObservableObject {
reload()
}
func setCheckForOffersOnOpen(_ enabled: Bool) {
defaults.set(enabled, forKey: Key.checkForOffersOnOpen)
reload()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload()

View File

@@ -65,7 +65,14 @@ protocol CoreGateway: AnyObject {
sources: [ShareSource],
transferName: String,
senderName: String
) async -> Result<Share, Error>
) async -> Result<ContactSendOutcome, Error>
/// Transfers this device is holding for contacts that were not running.
func heldOffers() async -> Result<[HeldOfferModel], Error>
/// Ask remembered devices whether they hold anything for this one.
///
/// Only ever called from a foreground transition or an explicit user action:
/// it reveals to every contact that this device is awake.
func pollContactsForOffers() async -> Result<UInt64, Error>
func forgetContact(endpointId: String) async -> Result<Void, Error>
func forgetAllContacts() async -> Result<UInt64, Error>
func blockContact(endpointId: String) async -> Result<Void, Error>

View File

@@ -269,6 +269,27 @@ struct IncomingOfferModel: Equatable, Identifiable, Sendable {
}
}
/// A transfer waiting for its target device to come back online.
struct HeldOfferModel: Equatable, Identifiable, Sendable {
let offerId: String
let endpointId: String
let transferId: UInt64
let transferName: String
let fileCount: UInt64
let totalBytes: UInt64
let createdAt: Int64
var id: String { offerId }
}
/// Outcome of sending straight to a remembered device.
struct ContactSendOutcome: Equatable, Sendable {
let share: Share
/// False when the device was not running: the transfer is held locally and
/// collected the next time that device opens the app.
let delivered: Bool
}
/// How long a remembered device stays reachable while unused. The countdown
/// restarts on every transfer.
enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable {

View File

@@ -335,7 +335,7 @@ final class CoreRepository: ObservableObject, CoreGateway {
sources: [ShareSource],
transferName: String,
senderName: String
) async -> Result<Share, Error> {
) async -> Result<ContactSendOutcome, Error> {
guard !isNetworkTransitionInProgress else {
return .failure(CoreNetworkLifecycleError.transitionInProgress)
}
@@ -355,10 +355,18 @@ final class CoreRepository: ObservableObject, CoreGateway {
accessMode: .approvalRequired
)
)
return result.toModel()
return ContactSendOutcome(share: result.share.toModel(), delivered: result.delivered)
}
}
func heldOffers() async -> Result<[HeldOfferModel], Error> {
await runCore { try self.requireCore().listHeldOffers().map { $0.toModel() } }
}
func pollContactsForOffers() async -> Result<UInt64, Error> {
await runCore { try self.requireCore().pollContactsForOffers() }
}
func forgetContact(endpointId: String) async -> Result<Void, Error> {
await runCore { try self.requireCore().forgetContact(endpointId: endpointId) }
}
@@ -657,6 +665,20 @@ extension IncomingOffer {
}
}
extension HeldOfferSummary {
func toModel() -> HeldOfferModel {
HeldOfferModel(
offerId: offerId,
endpointId: endpointId,
transferId: transferId,
transferName: transferName,
fileCount: fileCount,
totalBytes: totalBytes,
createdAt: createdAt
)
}
}
extension GrantLifetimeOption {
func toNative() -> GrantLifetimeSetting {
switch self {

View File

@@ -42,7 +42,7 @@ protocol FileSystemService {
transferName: String,
senderName: String,
destination: ShareDestination
) async -> Result<Share, Error>
) async -> Result<ContactSendOutcome, Error>
}
extension FileSystemService {

View File

@@ -32,6 +32,10 @@ struct ContactsState: Equatable {
var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = []
var suggestions: [PairingSuggestion] = []
/// Transfers this device is holding for contacts that were not running.
var heldOffers: [HeldOfferModel] = []
var checkForOffersOnOpen = false
var isCheckingForOffers = false
var selectedEndpointId: String?
var selected: DeviceContact? {
@@ -75,6 +79,7 @@ final class ContactsModel: ObservableObject {
self.preferences = preferences
self.fileSystemService = fileSystemService
state.grantLifetime = preferences.preferences.grantLifetime
state.checkForOffersOnOpen = preferences.preferences.checkForOffersOnOpen
repository.signals
.sink { [weak self] signal in
@@ -130,6 +135,9 @@ final class ContactsModel: ObservableObject {
if case .success(let blocked) = await repository.blockedContacts() {
state.blocked = blocked
}
if case .success(let held) = await repository.heldOffers() {
state.heldOffers = held
}
state.pendingPairings = await repository.pendingPairings()
await refreshOffers()
}
@@ -205,6 +213,40 @@ final class ContactsModel: ObservableObject {
preferences.declinePairingSuggestion(suggestion.endpointId)
}
// MARK: - Collecting waiting transfers
func setCheckForOffersOnOpen(_ enabled: Bool) {
state.checkForOffersOnOpen = enabled
preferences.setCheckForOffersOnOpen(enabled)
}
/// Called when the app comes to the foreground.
///
/// Opt-in, because asking every contact whether they have something waiting
/// also tells them the app was opened. Never runs in the background.
func checkForOffersOnForeground() async {
guard state.checkForOffersOnOpen else { return }
_ = await collectWaitingOffers()
}
/// Explicit "check now". Returns how many transfers were collected so the
/// caller can report an empty result, which a silent refresh cannot.
@discardableResult
func collectWaitingOffers() async -> UInt64 {
guard !state.isCheckingForOffers else { return 0 }
state.isCheckingForOffers = true
defer { state.isCheckingForOffers = false }
switch await repository.pollContactsForOffers() {
case .success(let collected):
await refreshOffers()
return collected
case .failure(let error):
messages.error(error)
return 0
}
}
// MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }
@@ -290,8 +332,13 @@ final class ContactsModel: ObservableObject {
)
await fileSystemService.discardPickedFiles(files)
switch result {
case .success:
messages.tryShow(UiMessage(text: .resource(L10n.Send.transferCreated), tone: .success))
case .success(let outcome):
// A closed app is a delay, not a failure: say so rather than
// reporting success for something nobody has received.
let text: UiText = outcome.delivered
? .resource(L10n.Send.transferCreated)
: .resource(L10n.Contacts.offerHeld)
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh()
case .failure(let error):
messages.error(error)

View File

@@ -7,6 +7,8 @@ import SwiftUI
/// not part of the send/receive flow.
struct ContactsScreen: View {
@ObservedObject var model: ContactsModel
/// Reports an empty result, which a silent refresh cannot convey.
let onNothingWaiting: () -> Void
var body: some View {
Form {
@@ -30,6 +32,24 @@ struct ContactsScreen: View {
}
}
if !model.state.heldOffers.isEmpty {
Section(String(localized: L10n.Contacts.waitingTitle)) {
ForEach(model.state.heldOffers) { offer in
VStack(alignment: .leading, spacing: 2) {
Text(offer.transferName)
Text(String(offer.endpointId.prefix(16)))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
Text(String(localized: L10n.Contacts.waitingHint))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
if !model.state.blocked.isEmpty {
Section(String(localized: L10n.Contacts.blockedTitle)) {
ForEach(model.state.blocked, id: \.self) { endpointId in
@@ -43,6 +63,18 @@ struct ContactsScreen: View {
}
}
CollectOffersSection(
enabled: model.state.checkForOffersOnOpen,
isChecking: model.state.isCheckingForOffers,
onToggle: model.setCheckForOffersOnOpen,
onCheckNow: {
Task {
let collected = await model.collectWaitingOffers()
if collected == 0 { onNothingWaiting() }
}
}
)
GrantLifetimeSection(
selection: model.state.grantLifetime,
onSelect: model.setGrantLifetime
@@ -129,6 +161,36 @@ private struct BlockedRow: View {
}
}
private struct CollectOffersSection: View {
let enabled: Bool
let isChecking: Bool
let onToggle: (Bool) -> Void
let onCheckNow: () -> Void
var body: some View {
Section {
Toggle(
String(localized: L10n.Contacts.checkOnOpen),
isOn: Binding(get: { enabled }, set: onToggle)
)
Button(action: onCheckNow) {
HStack {
Text(String(localized: L10n.Contacts.checkNow))
if isChecking {
Spacer()
ProgressView().controlSize(.small)
}
}
}
.disabled(isChecking)
} footer: {
// The privacy cost is the point of the setting, so it is stated
// where the switch is, not buried elsewhere.
Text(String(localized: L10n.Contacts.checkOnOpenHint))
}
}
}
private struct GrantLifetimeSection: View {
let selection: GrantLifetimeOption
let onSelect: (GrantLifetimeOption) -> Void

View File

@@ -355,7 +355,7 @@ final class SendModel: ObservableObject {
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
destination: .invitation(accessPolicy: current.accessPolicy)
)
switch result {
switch result.map(\.share) {
case .success(let share):
await fileSystemService.discardPickedFiles(current.selectedFiles)
if let thumb = current.selectedFiles.compactMap(\.thumbnailData).first {

View File

@@ -176,6 +176,12 @@ final class SettingsModel: ObservableObject {
loadDeviceInfo()
}
/// Surfaces "nothing waiting" from the contacts screen, which has no
/// message controller of its own.
func reportNothingWaiting() {
messages.tryShow(UiMessage(text: .resource(L10n.Contacts.checkNone), tone: .info))
}
func selectSection(_ section: SettingsSection) {
state.selectedSection = section
if section == .about || section == .bugReport {

View File

@@ -93,7 +93,9 @@ struct SettingsScreen: View {
// Contacts brings its own Form and push destination, so it is not wrapped
// in the shared section chrome.
if section == .contacts {
ContactsScreen(model: contacts)
ContactsScreen(model: contacts) {
model.reportNothingWaiting()
}
} else {
settingsSectionForm(section)
}

View File

@@ -60,7 +60,7 @@ struct IosFileSystemService: FileSystemService {
transferName: String,
senderName: String,
destination: ShareDestination
) async -> Result<Share, Error> {
) async -> Result<ContactSendOutcome, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
@@ -70,6 +70,7 @@ struct IosFileSystemService: FileSystemService {
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId):
return await repository.sendToContact(
endpointId: endpointId, sources: sources,

View File

@@ -40,7 +40,7 @@ struct MacFileSystemService: FileSystemService {
transferName: String,
senderName: String,
destination: ShareDestination
) async -> Result<Share, Error> {
) async -> Result<ContactSendOutcome, Error> {
guard !files.isEmpty else {
return .failure(InvitationError.shareEmpty)
}
@@ -68,6 +68,7 @@ struct MacFileSystemService: FileSystemService {
return await repository.shareSources(
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
)
.map { ContactSendOutcome(share: $0, delivered: true) }
case .contact(let endpointId):
return await repository.sendToContact(
endpointId: endpointId, sources: sources,