diff --git a/apple/Tests/ContactsModelTests.swift b/apple/Tests/ContactsModelTests.swift index 056e041..eb63d9d 100644 --- a/apple/Tests/ContactsModelTests.swift +++ b/apple/Tests/ContactsModelTests.swift @@ -223,3 +223,187 @@ final class ContactsModelTests: XCTestCase { 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) + } +} diff --git a/apple/VniDrop/App/RootView.swift b/apple/VniDrop/App/RootView.swift index 78c7789..abc60a0 100644 --- a/apple/VniDrop/App/RootView.swift +++ b/apple/VniDrop/App/RootView.swift @@ -319,6 +319,13 @@ private struct ContactPromptLayer: View { receiveModel.receiveOffered(ticket: ticket) } } + }, + onSuggestionResponse: { suggestion, accepted in + if accepted { + Task { await contacts.acceptSuggestion(suggestion) } + } else { + contacts.declineSuggestion(suggestion) + } } ) .onChange(of: promptKey) { _, key in @@ -332,6 +339,7 @@ private struct ContactPromptLayer: View { guard approvals.state.current == nil else { return nil } if let offer = contacts.state.currentOffer { return "offer-\(offer.offerId)" } if let pairing = contacts.state.currentPairing { return "pairing-\(pairing.endpointId)" } + if let suggestion = contacts.state.currentSuggestion { return "suggest-\(suggestion.endpointId)" } return nil } } diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index 25c761a..cf718ec 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -124,6 +124,9 @@ struct AppPreferences: Equatable { var relayConfiguration: RelayConfiguration /// Idle lifetime applied to grants this device issues from now on. 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 } struct AppPreferencesDefaults { @@ -148,6 +151,7 @@ final class AppPreferencesRepository: ObservableObject { static let diagnosticsInstallId = "diagnostics_install_id" static let relayConfiguration = "relay_configuration" static let grantLifetime = "grant_lifetime" + static let declinedPairingSuggestions = "declined_pairing_suggestions" } init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { @@ -163,13 +167,15 @@ final class AppPreferencesRepository: ObservableObject { let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" let grantLifetime = defaults.string(forKey: Key.grantLifetime) .flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90 + let declined = Set(defaults.stringArray(forKey: Key.declinedPairingSuggestions) ?? []) return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, diagnosticsInstallId: installId, relayConfiguration: resolveRelayConfiguration(defaults), - grantLifetime: grantLifetime + grantLifetime: grantLifetime, + declinedPairingSuggestions: declined ) } @@ -215,6 +221,22 @@ final class AppPreferencesRepository: ObservableObject { 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) { defaults.set(lifetime.rawValue, forKey: Key.grantLifetime) reload() diff --git a/apple/VniDrop/Features/Contacts/ContactPrompts.swift b/apple/VniDrop/Features/Contacts/ContactPrompts.swift index 1edfddc..9eace1f 100644 --- a/apple/VniDrop/Features/Contacts/ContactPrompts.swift +++ b/apple/VniDrop/Features/Contacts/ContactPrompts.swift @@ -14,12 +14,14 @@ struct ContactPromptHost: View { let state: ContactsState let onPairingResponse: (String, Bool) -> Void let onOfferResponse: (String, Bool) -> Void + let onSuggestionResponse: (PairingSuggestion, Bool) -> Void var body: some View { Color.clear .sheet(isPresented: $isPresented) { - // An incoming transfer is the more urgent of the two, and a - // pairing offer keeps until the consent window lapses. + // Ordered by who is waiting: a sender is blocked on an offer, a + // pairing request keeps until its consent window lapses, and a + // post-transfer suggestion has nobody waiting at all. if let offer = state.currentOffer { OfferSheet( offer: offer, @@ -36,6 +38,16 @@ struct ContactPromptHost: View { ) .interactiveDismissDisabled(true) .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) } } + +/// "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) + } +} diff --git a/apple/VniDrop/Features/Contacts/ContactsModel.swift b/apple/VniDrop/Features/Contacts/ContactsModel.swift index bcc5a38..89391f2 100644 --- a/apple/VniDrop/Features/Contacts/ContactsModel.swift +++ b/apple/VniDrop/Features/Contacts/ContactsModel.swift @@ -1,6 +1,25 @@ import Combine 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 { var contacts: [DeviceContact] = [] var blocked: [String] = [] @@ -12,6 +31,7 @@ struct ContactsState: Equatable { /// blocking the rest of the list. var busyEndpoints: Set = [] var busyOfferIds: Set = [] + var suggestions: [PairingSuggestion] = [] var selectedEndpointId: String? var selected: DeviceContact? { @@ -23,6 +43,7 @@ struct ContactsState: Equatable { /// sheets on top of each other reads as a loop of dialogs. var currentPairing: PendingPairingModel? { pendingPairings.first } var currentOffer: IncomingOfferModel? { pendingOffers.first } + var currentSuggestion: PairingSuggestion? { suggestions.first } } /// Drives the device-history surfaces: the list, its detail, and the two @@ -54,12 +75,22 @@ final class ContactsModel: ObservableObject { Task { await self.refresh() } case .offersChanged: 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 } } .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 .map(\.isInitialized) .removeDuplicates() @@ -98,6 +129,73 @@ final class ContactsModel: ObservableObject { 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 func select(_ endpointId: String?) { state.selectedEndpointId = endpointId }