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

@@ -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 {