feat(apple): offer to remember a device after a transfer

Closes the loop: until now nothing in the UI could create a contact, so the
list stayed empty unless the peer initiated.

A completed receive names its sender and a completed delivery names its
receiver, so both sides get the suggestion. Declining is persisted, or every
later transfer with the same device would re-ask a question already
answered; pairing deliberately afterwards clears that.

The suggestion sheet ranks below the two other prompts, since nobody is
waiting on the answer.
This commit is contained in:
2026-08-06 18:06:19 +02:00
parent 1369df4578
commit dc23e87c56
5 changed files with 368 additions and 4 deletions

View File

@@ -223,3 +223,187 @@ final class ContactsModelTests: XCTestCase {
XCTAssertEqual(model.state.contacts.first?.canSend, false) XCTAssertEqual(model.state.contacts.first?.canSend, false)
} }
} }
// MARK: - Post-transfer suggestions
@MainActor
final class PairingSuggestionTests: XCTestCase {
private func makeModel(
_ gateway: FakeCoreGateway,
defaults: UserDefaults
) -> (ContactsModel, AppPreferencesRepository) {
let preferences = AppPreferencesRepository(
defaults: defaults,
fallback: AppPreferencesDefaults(
username: "tester",
receiveFolder: ReceiveFolder(
kind: .fileSystemPath,
value: "/tmp",
displayName: "Downloads"
),
themeMode: .system
)
)
let model = ContactsModel(
repository: gateway,
messages: UiMessageController(),
preferences: preferences
)
return (model, preferences)
}
private func newDefaults() -> UserDefaults {
UserDefaults(suiteName: "suggestion-tests-\(UUID().uuidString)")!
}
private func completedReceive(from peerId: String?) -> Transfer {
Transfer(
localId: "local-1",
transferId: 1,
direction: .receive,
status: .done,
peerId: peerId,
transferName: "photos",
contentHash: nil,
fileCount: 1,
totalSize: 10,
ticket: nil,
accessPolicy: .requireApproval,
createdAt: 0,
updatedAt: 0
)
}
private func state(with transfers: [Transfer]) -> CoreState {
var core = CoreState()
core.isInitialized = true
core.transfers = transfers
return core
}
func testCompletedReceiveSuggestsItsSender() async {
let gateway = FakeCoreGateway()
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
XCTAssertEqual(model.state.currentSuggestion?.endpointId, "sender-endpoint")
}
/// A transfer that never recorded a peer cannot be turned into a suggestion.
func testReceiveWithoutAPeerIsNotSuggested() async {
let gateway = FakeCoreGateway()
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: nil)]))
await Task.yield()
XCTAssertNil(model.state.currentSuggestion)
}
func testAlreadyRememberedDeviceIsNotSuggested() async {
let gateway = FakeCoreGateway()
gateway.contactsResult = .success([
DeviceContact(
endpointId: "sender-endpoint",
localLabel: nil,
remoteDisplayName: nil,
lastTransferAt: nil,
createdAt: 0,
canSend: true
)
])
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
XCTAssertNil(model.state.currentSuggestion)
}
func testBlockedDeviceIsNotSuggested() async {
let gateway = FakeCoreGateway()
gateway.blockedResult = .success(["sender-endpoint"])
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
XCTAssertNil(model.state.currentSuggestion)
}
/// Declining has to stick, or every later transfer with the same device
/// re-asks the question the user already answered.
func testDecliningIsRememberedAcrossLaterTransfers() async {
let defaults = newDefaults()
let gateway = FakeCoreGateway()
let (model, preferences) = makeModel(gateway, defaults: defaults)
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
model.declineSuggestion(suggestion!)
XCTAssertNil(model.state.currentSuggestion)
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
// A second transfer with the same device must stay silent.
gateway.setState(CoreState())
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
XCTAssertNil(model.state.currentSuggestion)
}
func testAcceptingASuggestionIssuesAGrantUnderTheLocalUsername() async {
let gateway = FakeCoreGateway()
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
let suggestion = try? XCTUnwrap(model.state.currentSuggestion)
await model.acceptSuggestion(suggestion!)
XCTAssertEqual(gateway.allowedDevices.map(\.endpointId), ["sender-endpoint"])
XCTAssertEqual(gateway.allowedDevices.first?.displayName, "tester")
XCTAssertNil(model.state.currentSuggestion)
}
/// Pairing deliberately after declining should work, so the decline is
/// cleared rather than blocking the device forever.
func testAcceptingClearsAnEarlierDecline() async {
let defaults = newDefaults()
let gateway = FakeCoreGateway()
let (model, preferences) = makeModel(gateway, defaults: defaults)
let suggestion = PairingSuggestion(
endpointId: "sender-endpoint",
displayName: nil,
transferName: nil
)
model.declineSuggestion(suggestion)
XCTAssertTrue(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
await model.acceptSuggestion(suggestion)
XCTAssertFalse(preferences.preferences.declinedPairingSuggestions.contains("sender-endpoint"))
}
func testTheSameDeviceIsOnlySuggestedOnce() async {
let gateway = FakeCoreGateway()
let (model, _) = makeModel(gateway, defaults: newDefaults())
await model.refresh()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
gateway.setState(state(with: [completedReceive(from: "sender-endpoint")]))
await Task.yield()
XCTAssertEqual(model.state.suggestions.count, 1)
}
}

View File

@@ -319,6 +319,13 @@ private struct ContactPromptLayer: View {
receiveModel.receiveOffered(ticket: ticket) receiveModel.receiveOffered(ticket: ticket)
} }
} }
},
onSuggestionResponse: { suggestion, accepted in
if accepted {
Task { await contacts.acceptSuggestion(suggestion) }
} else {
contacts.declineSuggestion(suggestion)
}
} }
) )
.onChange(of: promptKey) { _, key in .onChange(of: promptKey) { _, key in
@@ -332,6 +339,7 @@ private struct ContactPromptLayer: View {
guard approvals.state.current == nil else { return nil } guard approvals.state.current == nil else { return nil }
if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" } if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" }
if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" } if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" }
if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" }
return nil return nil
} }
} }

View File

@@ -124,6 +124,9 @@ struct AppPreferences: Equatable {
var relayConfiguration: RelayConfiguration var relayConfiguration: RelayConfiguration
/// Idle lifetime applied to grants this device issues from now on. /// Idle lifetime applied to grants this device issues from now on.
var grantLifetime: GrantLifetimeOption var grantLifetime: GrantLifetimeOption
/// Devices the user declined to remember. Persisted so a repeat transfer
/// with the same device does not re-ask forever.
var declinedPairingSuggestions: Set<String>
} }
struct AppPreferencesDefaults { struct AppPreferencesDefaults {
@@ -148,6 +151,7 @@ final class AppPreferencesRepository: ObservableObject {
static let diagnosticsInstallId = "diagnostics_install_id" static let diagnosticsInstallId = "diagnostics_install_id"
static let relayConfiguration = "relay_configuration" static let relayConfiguration = "relay_configuration"
static let grantLifetime = "grant_lifetime" static let grantLifetime = "grant_lifetime"
static let declinedPairingSuggestions = "declined_pairing_suggestions"
} }
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
@@ -163,13 +167,15 @@ final class AppPreferencesRepository: ObservableObject {
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
let grantLifetime = defaults.string(forKey: Key.grantLifetime) let grantLifetime = defaults.string(forKey: Key.grantLifetime)
.flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90 .flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90
let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? [])
return AppPreferences( return AppPreferences(
username: username, username: username,
receiveFolder: folder, receiveFolder: folder,
themeMode: themeMode, themeMode: themeMode,
diagnosticsInstallId: installId, diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults), relayConfiguration: resolveRelayConfiguration(defaults),
grantLifetime: grantLifetime grantLifetime: grantLifetime,
declinedPairingSuggestions: declined
) )
} }
@@ -215,6 +221,22 @@ final class AppPreferencesRepository: ObservableObject {
setReceiveFolder(fallback.receiveFolder) setReceiveFolder(fallback.receiveFolder)
} }
func declinePairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
declined.insert(endpointId)
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
/// Clears the decline so the device can be suggested again, used when the
/// user pairs with it deliberately.
func clearDeclinedPairingSuggestion(_ endpointId: String) {
var declined = preferences.declinedPairingSuggestions
guard declined.remove(endpointId) != nil else { return }
defaults.set(Array(declined), forKey: Key.declinedPairingSuggestions)
reload()
}
func setGrantLifetime(_ lifetime: GrantLifetimeOption) { func setGrantLifetime(_ lifetime: GrantLifetimeOption) {
defaults.set(lifetime.rawValue, forKey: Key.grantLifetime) defaults.set(lifetime.rawValue, forKey: Key.grantLifetime)
reload() reload()

View File

@@ -14,12 +14,14 @@ struct ContactPromptHost: View {
let state: ContactsState let state: ContactsState
let onPairingResponse: (String, Bool) -> Void let onPairingResponse: (String, Bool) -> Void
let onOfferResponse: (String, Bool) -> Void let onOfferResponse: (String, Bool) -> Void
let onSuggestionResponse: (PairingSuggestion, Bool) -> Void
var body: some View { var body: some View {
Color.clear Color.clear
.sheet(isPresented: $isPresented) { .sheet(isPresented: $isPresented) {
// An incoming transfer is the more urgent of the two, and a // Ordered by who is waiting: a sender is blocked on an offer, a
// pairing offer keeps until the consent window lapses. // pairing request keeps until its consent window lapses, and a
// post-transfer suggestion has nobody waiting at all.
if let offer = state.currentOffer { if let offer = state.currentOffer {
OfferSheet( OfferSheet(
offer: offer, offer: offer,
@@ -36,6 +38,16 @@ struct ContactPromptHost: View {
) )
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)
.modifier(ContactPromptDetents()) .modifier(ContactPromptDetents())
} else if let suggestion = state.currentSuggestion {
// Lowest priority: nobody is waiting on this answer, it just
// follows a transfer that already finished.
SuggestionSheet(
suggestion: suggestion,
busy: state.busyEndpoints.contains(suggestion.endpointId),
onRespond: onSuggestionResponse
)
.interactiveDismissDisabled(true)
.modifier(ContactPromptDetents())
} }
} }
} }
@@ -131,3 +143,43 @@ private struct PairingSheet: View {
.padding(20) .padding(20)
} }
} }
/// "You just transferred with this device. Let it reach you next time?"
private struct SuggestionSheet: View {
let suggestion: PairingSuggestion
let busy: Bool
let onRespond: (PairingSuggestion, Bool) -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemSymbol: .clockArrowCirclepath)
.font(.system(size: 44))
.foregroundStyle(.tint)
.padding(.top, 12)
Text(String(localized: L10n.Pairing.allowTitle))
.font(.title2).fontWeight(.semibold)
Text(L10n.Pairing.requestBody(device: suggestion.resolvedName))
.multilineTextAlignment(.center)
Text(String(localized: L10n.Pairing.allowBody))
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
Spacer(minLength: 0)
HStack(spacing: 12) {
Button(role: .cancel) {
onRespond(suggestion, false)
} label: {
Text(String(localized: L10n.Pairing.decline)).frame(maxWidth: .infinity)
}
Button {
onRespond(suggestion, true)
} label: {
Text(String(localized: L10n.Pairing.allowConfirm)).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
}
.disabled(busy)
}
.padding(20)
}
}

View File

@@ -1,6 +1,25 @@
import Combine import Combine
import Foundation import Foundation
/// A device worth remembering after a completed transfer.
///
/// Only a suggestion: nothing is issued until the user agrees, because being
/// reachable is a standing permission and a transfer is a one-off.
struct PairingSuggestion: Equatable, Identifiable {
let endpointId: String
let displayName: String?
let transferName: String?
var id: String { endpointId }
var resolvedName: String {
guard let displayName, !displayName.isEmpty else {
return String(localized: L10n.Approval.nearbyDevice)
}
return displayName
}
}
struct ContactsState: Equatable { struct ContactsState: Equatable {
var contacts: [DeviceContact] = [] var contacts: [DeviceContact] = []
var blocked: [String] = [] var blocked: [String] = []
@@ -12,6 +31,7 @@ struct ContactsState: Equatable {
/// blocking the rest of the list. /// blocking the rest of the list.
var busyEndpoints: Set<String> = [] var busyEndpoints: Set<String> = []
var busyOfferIds: Set<String> = [] var busyOfferIds: Set<String> = []
var suggestions: [PairingSuggestion] = []
var selectedEndpointId: String? var selectedEndpointId: String?
var selected: DeviceContact? { var selected: DeviceContact? {
@@ -23,6 +43,7 @@ struct ContactsState: Equatable {
/// sheets on top of each other reads as a loop of dialogs. /// sheets on top of each other reads as a loop of dialogs.
var currentPairing: PendingPairingModel? { pendingPairings.first } var currentPairing: PendingPairingModel? { pendingPairings.first }
var currentOffer: IncomingOfferModel? { pendingOffers.first } var currentOffer: IncomingOfferModel? { pendingOffers.first }
var currentSuggestion: PairingSuggestion? { suggestions.first }
} }
/// Drives the device-history surfaces: the list, its detail, and the two /// Drives the device-history surfaces: the list, its detail, and the two
@@ -54,12 +75,22 @@ final class ContactsModel: ObservableObject {
Task { await self.refresh() } Task { await self.refresh() }
case .offersChanged: case .offersChanged:
Task { await self.refreshOffers() } Task { await self.refreshOffers() }
case .approvalChanged, .receiverHistoryChanged, .transfersChanged: case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId):
// A completed delivery names the device that received from us.
Task { await self.considerSendPeers(transferId: transferId) }
case .approvalChanged:
break break
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
repository.statePublisher
.sink { [weak self] core in
guard let self, core.isInitialized else { return }
self.considerReceivePeers(core.transfers)
}
.store(in: &cancellables)
repository.statePublisher repository.statePublisher
.map(\.isInitialized) .map(\.isInitialized)
.removeDuplicates() .removeDuplicates()
@@ -98,6 +129,73 @@ final class ContactsModel: ObservableObject {
state.pendingOffers = await repository.pendingOffers() state.pendingOffers = await repository.pendingOffers()
} }
// MARK: - Post-transfer suggestions
/// A completed receive names its sender, so that device becomes a candidate.
private func considerReceivePeers(_ transfers: [Transfer]) {
let candidates = transfers
.filter { $0.direction == .receive && $0.status == .done }
.compactMap { transfer -> PairingSuggestion? in
guard let peerId = transfer.peerId else { return nil }
return PairingSuggestion(
endpointId: peerId,
displayName: nil,
transferName: transfer.transferName
)
}
add(suggestions: candidates)
}
/// A completed delivery names the device we sent to.
private func considerSendPeers(transferId: UInt64) async {
guard case .success(let requests) = await repository.receiverRequests(transferId: transferId) else {
return
}
let candidates = requests
.filter { $0.status == .completed }
.map { request in
PairingSuggestion(
endpointId: request.remoteEndpointId,
displayName: request.receiverName ?? request.receiverDeviceName,
transferName: request.transferName
)
}
add(suggestions: candidates)
}
/// Filters candidates down to devices actually worth asking about.
private func add(suggestions candidates: [PairingSuggestion]) {
let known = Set(state.contacts.map(\.endpointId))
let blocked = Set(state.blocked)
let declined = preferences.preferences.declinedPairingSuggestions
let pending = Set(state.suggestions.map(\.endpointId))
let fresh = candidates.filter { candidate in
!known.contains(candidate.endpointId)
&& !blocked.contains(candidate.endpointId)
&& !declined.contains(candidate.endpointId)
&& !pending.contains(candidate.endpointId)
}
guard !fresh.isEmpty else { return }
state.suggestions.append(contentsOf: fresh)
}
/// Agree to be reachable by a suggested device.
func acceptSuggestion(_ suggestion: PairingSuggestion) async {
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
preferences.clearDeclinedPairingSuggestion(suggestion.endpointId)
await allowDeviceToReachMe(
endpointId: suggestion.endpointId,
displayName: preferences.preferences.username
)
}
/// Decline, and remember the decline so the next transfer does not re-ask.
func declineSuggestion(_ suggestion: PairingSuggestion) {
state.suggestions.removeAll { $0.endpointId == suggestion.endpointId }
preferences.declinePairingSuggestion(suggestion.endpointId)
}
// MARK: - Selection // MARK: - Selection
func select(_ endpointId: String?) { state.selectedEndpointId = endpointId } func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }