diff --git a/apple/Tests/Fakes.swift b/apple/Tests/Fakes.swift index d2e8520..c610183 100644 --- a/apple/Tests/Fakes.swift +++ b/apple/Tests/Fakes.swift @@ -81,6 +81,80 @@ final class FakeCoreGateway: CoreGateway { return responseResult } func refresh() async -> Result { .success(()) } + + // MARK: Device history + + var contactsResult: Result<[DeviceContact], Error> = .success([]) + var pairings: [PendingPairingModel] = [] + var offers: [IncomingOfferModel] = [] + var respondToPairingResult: Result = .success(true) + /// Ticket handed back when an offer is accepted; nil models a declined one. + var offerTicket: String? = "vnd1:offered" + var sendToContactResult: Result = .failure(TestError.unimplemented) + var forgetContactResult: Result = .success(()) + var blockedResult: Result<[String], Error> = .success([]) + + private(set) var allowedDevices: [(endpointId: String, displayName: String?)] = [] + private(set) var pairingResponses: [(endpointId: String, accepted: Bool)] = [] + private(set) var offerResponses: [(offerId: String, accepted: Bool)] = [] + private(set) var forgottenContacts: [String] = [] + private(set) var forgetAllCount = 0 + private(set) var blockedContactIds: [String] = [] + private(set) var unblockedContactIds: [String] = [] + private(set) var contactLabels: [(endpointId: String, label: String?)] = [] + private(set) var grantLifetimes: [GrantLifetimeOption] = [] + private(set) var sentToContacts: [String] = [] + + func contacts() async -> Result<[DeviceContact], Error> { contactsResult } + func pendingPairings() async -> [PendingPairingModel] { pairings } + func pendingOffers() async -> [IncomingOfferModel] { offers } + func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result { + allowedDevices.append((endpointId, displayName)) + return .success(()) + } + func respondToPairing(endpointId: String, accepted: Bool) async -> Result { + pairingResponses.append((endpointId, accepted)) + if case .success = respondToPairingResult { + pairings.removeAll { $0.endpointId == endpointId } + } + return respondToPairingResult + } + func respondToOffer(offerId: String, accepted: Bool) async -> String? { + offerResponses.append((offerId, accepted)) + offers.removeAll { $0.offerId == offerId } + return accepted ? offerTicket : nil + } + func sendToContact( + endpointId: String, + sources: [ShareSource], + transferName: String, + senderName: String + ) async -> Result { + sentToContacts.append(endpointId) + return sendToContactResult + } + func forgetContact(endpointId: String) async -> Result { + forgottenContacts.append(endpointId) + return forgetContactResult + } + func forgetAllContacts() async -> Result { + forgetAllCount += 1 + return .success(0) + } + func blockContact(endpointId: String) async -> Result { + blockedContactIds.append(endpointId) + return .success(()) + } + func unblockContact(endpointId: String) async -> Result { + unblockedContactIds.append(endpointId) + return .success(()) + } + func blockedContacts() async -> Result<[String], Error> { blockedResult } + func setContactLabel(endpointId: String, label: String?) async -> Result { + contactLabels.append((endpointId, label)) + return .success(()) + } + func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { grantLifetimes.append(lifetime) } } /// Minimal `FileSystemService` fake — a writable path receive folder, no reveal. diff --git a/apple/VniDrop/Core/CoreGateway.swift b/apple/VniDrop/Core/CoreGateway.swift index e7a5620..e147889 100644 --- a/apple/VniDrop/Core/CoreGateway.swift +++ b/apple/VniDrop/Core/CoreGateway.swift @@ -47,4 +47,30 @@ protocol CoreGateway: AnyObject { func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result func refresh() async -> Result + + // MARK: Device history + + func contacts() async -> Result<[DeviceContact], Error> + func pendingPairings() async -> [PendingPairingModel] + func pendingOffers() async -> [IncomingOfferModel] + /// Hand a device a revocable capability to reach this one. + func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result + /// Accept or decline a device's offer to be remembered. + func respondToPairing(endpointId: String, accepted: Bool) async -> Result + /// Answer an incoming offer. Returns the ticket on acceptance, which the + /// caller passes to `receive` with a platform-appropriate destination. + func respondToOffer(offerId: String, accepted: Bool) async -> String? + func sendToContact( + endpointId: String, + sources: [ShareSource], + transferName: String, + senderName: String + ) async -> Result + func forgetContact(endpointId: String) async -> Result + func forgetAllContacts() async -> Result + func blockContact(endpointId: String) async -> Result + func unblockContact(endpointId: String) async -> Result + func blockedContacts() async -> Result<[String], Error> + func setContactLabel(endpointId: String, label: String?) async -> Result + func setGrantLifetime(_ lifetime: GrantLifetimeOption) async } diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index ca594f2..567d2d2 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -168,6 +168,10 @@ enum CoreSignal: Equatable, Sendable { case receiverHistoryChanged(transferId: UInt64) /// Transfer status/history changed enough to re-read the durable snapshot. case transfersChanged(transferId: UInt64) + /// Device history changed: a contact was added, forgotten, or blocked. + case contactsChanged + /// An incoming offer arrived or was answered. + case offersChanged } // MARK: - Transfer helpers (ported from AppUiModels.kt) @@ -186,3 +190,91 @@ extension TransferStatus { self == .done || self == .failed || self == .cancelled } } + +// MARK: - Device history + +/// A device the user has chosen to remember. +/// +/// `localLabel` is the user's own name for the device and is authoritative for +/// display; `remoteDisplayName` is whatever the device last called itself and is +/// untrusted. The endpoint id is the only real identity. +struct DeviceContact: Equatable, Identifiable, Sendable { + let endpointId: String + let localLabel: String? + let remoteDisplayName: String? + let lastTransferAt: Int64? + let createdAt: Int64 + /// Whether a live grant is held. False once the peer revoked, the grant + /// lapsed, or the peer reinstalled and lost its identity. + let canSend: Bool + + var id: String { endpointId } + + /// Name to show, preferring the local label the peer cannot influence. + var displayName: String { + if let localLabel, !localLabel.isEmpty { return localLabel } + if let remoteDisplayName, !remoteDisplayName.isEmpty { return remoteDisplayName } + return String(localized: L10n.Approval.nearbyDevice) + } + + /// Short prefix of the endpoint id, for telling apart devices claiming the + /// same name. + var shortFingerprint: String { String(endpointId.prefix(8)) } +} + +/// A device offering to be remembered, awaiting this user's decision. +struct PendingPairingModel: Equatable, Identifiable, Sendable { + let endpointId: String + let displayName: String? + let receivedAt: Int64 + + var id: String { endpointId } + + var resolvedName: String { + guard let displayName, !displayName.isEmpty else { + return String(localized: L10n.Approval.nearbyDevice) + } + return displayName + } +} + +/// A transfer a remembered device is offering. Carries no ticket: that is a +/// capability and the core releases it only once the user accepts. +struct IncomingOfferModel: Equatable, Identifiable, Sendable { + let offerId: String + let fromEndpointId: String + let senderDisplayName: String? + let transferName: String + let fileCount: UInt64 + let totalBytes: UInt64 + let receivedAt: Int64 + + var id: String { offerId } + + var resolvedSenderName: String { + guard let senderDisplayName, !senderDisplayName.isEmpty else { + return String(localized: L10n.Approval.nearbyDevice) + } + return senderDisplayName + } +} + +/// How long a remembered device stays reachable while unused. The countdown +/// restarts on every transfer. +enum GrantLifetimeOption: String, CaseIterable, Identifiable, Sendable { + case days30 + case days90 + case days365 + case never + + var id: String { rawValue } + + var days: Int? { + switch self { + case .days30: return 30 + case .days90: return 90 + case .days365: return 365 + case .never: return nil + } + } +} diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index 997feba..e957efd 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -293,6 +293,102 @@ final class CoreRepository: ObservableObject, CoreGateway { } } + // MARK: - Device history + + func contacts() async -> Result<[DeviceContact], Error> { + await runCore { + try self.requireCore().listContacts().map { $0.toModel() } + } + } + + func pendingPairings() async -> [PendingPairingModel] { + let result = await runCore { try self.requireCore().listPendingPairings().map { $0.toModel() } } + return (try? result.get()) ?? [] + } + + func pendingOffers() async -> [IncomingOfferModel] { + let result = await runCore { try self.requireCore().listPendingOffers().map { $0.toModel() } } + return (try? result.get()) ?? [] + } + + func allowDeviceToReachMe(endpointId: String, displayName: String?) async -> Result { + await runCore { + try self.requireCore().allowDeviceToReachMe(endpointId: endpointId, displayName: displayName) + } + } + + func respondToPairing(endpointId: String, accepted: Bool) async -> Result { + await runCore { + try self.requireCore().respondToPairing(endpointId: endpointId, accepted: accepted) + } + } + + func respondToOffer(offerId: String, accepted: Bool) async -> String? { + let result = await runCore { + try self.requireCore().respondToOffer(offerId: offerId, accepted: accepted) + } + return (try? result.get()) ?? nil + } + + func sendToContact( + endpointId: String, + sources: [ShareSource], + transferName: String, + senderName: String + ) async -> Result { + guard !isNetworkTransitionInProgress else { + return .failure(CoreNetworkLifecycleError.transitionInProgress) + } + guard !sources.isEmpty else { + return .failure(InvitationError.shareEmpty) + } + return await runCore { + // The access mode is forced to approval-required by the core for + // offers; passing it here only keeps the metadata well-formed. + let result = try self.requireCore().sendToContact( + endpointId: endpointId, + sources: sources, + metadata: ShareMetadataInput( + transferId: Self.nextTransferId(), + transferName: transferName.isEmpty ? nil : transferName, + senderName: senderName.isEmpty ? nil : senderName, + accessMode: .approvalRequired + ) + ) + return result.toModel() + } + } + + func forgetContact(endpointId: String) async -> Result { + await runCore { try self.requireCore().forgetContact(endpointId: endpointId) } + } + + func forgetAllContacts() async -> Result { + await runCore { try self.requireCore().forgetAllContacts() } + } + + func blockContact(endpointId: String) async -> Result { + await runCore { try self.requireCore().blockContact(endpointId: endpointId) } + } + + func unblockContact(endpointId: String) async -> Result { + await runCore { try self.requireCore().unblockContact(endpointId: endpointId) } + } + + func blockedContacts() async -> Result<[String], Error> { + await runCore { try self.requireCore().listBlockedContacts() } + } + + func setContactLabel(endpointId: String, label: String?) async -> Result { + await runCore { + try self.requireCore().setContactLabel(endpointId: endpointId, label: label) + } + } + + func setGrantLifetime(_ lifetime: GrantLifetimeOption) async { + _ = await runCore { try self.requireCore().setGrantLifetime(lifetime: lifetime.toNative()) } + } + // MARK: - Event sink handling (ported from CoreRepository.sink) private func handle(event: CoreEvent) { @@ -302,6 +398,14 @@ final class CoreRepository: ObservableObject, CoreGateway { if events.count > Self.maxEvents { events = Array(events.prefix(Self.maxEvents)) } state.events = events + // Contacts and offers are endpoint-scoped: they carry no transfer id, so + // they are dispatched before the transfer-scoped handling below. + switch model.phase { + case "contacts": signalsSubject.send(.contactsChanged) + case "offer": signalsSubject.send(.offersChanged) + default: break + } + guard let transferId = model.transferId else { return } switch model.phase { case "approval", "access": signalsSubject.send(.approvalChanged(transferId: transferId)) @@ -519,3 +623,47 @@ private extension ReceiverRequest { } } } + +extension ContactSummary { + func toModel() -> DeviceContact { + DeviceContact( + endpointId: endpointId, + localLabel: localLabel, + remoteDisplayName: remoteDisplayName, + lastTransferAt: lastTransferAt, + createdAt: createdAt, + canSend: canSend + ) + } +} + +extension PendingPairing { + func toModel() -> PendingPairingModel { + PendingPairingModel(endpointId: endpointId, displayName: displayName, receivedAt: receivedAt) + } +} + +extension IncomingOffer { + func toModel() -> IncomingOfferModel { + IncomingOfferModel( + offerId: offerId, + fromEndpointId: fromEndpointId, + senderDisplayName: senderDisplayName, + transferName: transferName, + fileCount: fileCount, + totalBytes: totalBytes, + receivedAt: receivedAt + ) + } +} + +extension GrantLifetimeOption { + func toNative() -> GrantLifetimeSetting { + switch self { + case .days30: return .days30 + case .days90: return .days90 + case .days365: return .days365 + case .never: return .never + } + } +} diff --git a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift index 4c52fb7..da10bd2 100644 --- a/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift +++ b/apple/VniDrop/Features/Notifications/TransferNotificationCoordinator.swift @@ -111,7 +111,7 @@ final class TransferNotificationCoordinator: ObservableObject { switch signal { case .receiverHistoryChanged(let transferId), .transfersChanged(let transferId): Task { await self.syncReceivers(transferId: transferId) } - case .approvalChanged: + case .approvalChanged, .contactsChanged, .offersChanged: break } } diff --git a/apple/VniDrop/Features/Send/SendModel.swift b/apple/VniDrop/Features/Send/SendModel.swift index 88410cc..3e20f6b 100644 --- a/apple/VniDrop/Features/Send/SendModel.swift +++ b/apple/VniDrop/Features/Send/SendModel.swift @@ -96,6 +96,8 @@ final class SendModel: ObservableObject { case .receiverHistoryChanged(let id), .approvalChanged(let id): if id == self.state.selectedTransferId { self.refreshReceivers(id) } self.refreshReceiverStatuses(for: id) + case .contactsChanged, .offersChanged: + break } } .store(in: &cancellables)