diff --git a/apple/Tests/ContactsModelTests.swift b/apple/Tests/ContactsModelTests.swift new file mode 100644 index 0000000..056e041 --- /dev/null +++ b/apple/Tests/ContactsModelTests.swift @@ -0,0 +1,225 @@ +import XCTest +@testable import VniDrop + +@MainActor +final class ContactsModelTests: XCTestCase { + private func makeModel( + _ gateway: FakeCoreGateway + ) -> (ContactsModel, AppPreferencesRepository) { + let defaults = UserDefaults(suiteName: "contacts-tests-\(UUID().uuidString)")! + 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 contact( + _ endpointId: String, + label: String? = nil, + remoteName: String? = nil, + canSend: Bool = true + ) -> DeviceContact { + DeviceContact( + endpointId: endpointId, + localLabel: label, + remoteDisplayName: remoteName, + lastTransferAt: nil, + createdAt: 0, + canSend: canSend + ) + } + + private func offer(_ offerId: String, from endpointId: String = "peer") -> IncomingOfferModel { + IncomingOfferModel( + offerId: offerId, + fromEndpointId: endpointId, + senderDisplayName: "Peer", + transferName: "photos", + fileCount: 2, + totalBytes: 1_024, + receivedAt: 0 + ) + } + + func testRefreshLoadsContactsBlocksAndPrompts() async { + let gateway = FakeCoreGateway() + gateway.contactsResult = .success([contact("a"), contact("b")]) + gateway.blockedResult = .success(["blocked-one"]) + gateway.pairings = [PendingPairingModel(endpointId: "c", displayName: "Laptop", receivedAt: 0)] + gateway.offers = [offer("offer-1")] + let (model, _) = makeModel(gateway) + + await model.refresh() + + XCTAssertEqual(model.state.contacts.count, 2) + XCTAssertEqual(model.state.blocked, ["blocked-one"]) + XCTAssertEqual(model.state.currentPairing?.endpointId, "c") + XCTAssertEqual(model.state.currentOffer?.offerId, "offer-1") + XCTAssertFalse(model.state.isLoading) + } + + /// Accepting an offer is the only path that yields a ticket; the caller needs + /// it to run the receive with its own destination. + func testAcceptingAnOfferReturnsTheTicket() async { + let gateway = FakeCoreGateway() + gateway.offers = [offer("offer-1")] + gateway.offerTicket = "vnd1:abc" + let (model, _) = makeModel(gateway) + await model.refresh() + + let ticket = await model.respondToOffer(offerId: "offer-1", accepted: true) + + XCTAssertEqual(ticket, "vnd1:abc") + XCTAssertTrue(model.state.pendingOffers.isEmpty) + XCTAssertEqual(gateway.offerResponses.map(\.accepted), [true]) + } + + func testDecliningAnOfferYieldsNoTicketAndClearsThePrompt() async { + let gateway = FakeCoreGateway() + gateway.offers = [offer("offer-1")] + let (model, _) = makeModel(gateway) + await model.refresh() + + let ticket = await model.respondToOffer(offerId: "offer-1", accepted: false) + + XCTAssertNil(ticket, "a declined offer must not hand over a capability") + XCTAssertTrue(model.state.pendingOffers.isEmpty) + } + + /// Declining to be remembered must leave nothing behind for the peer. + func testDecliningPairingClearsThePromptWithoutAddingAContact() async { + let gateway = FakeCoreGateway() + gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: nil, receivedAt: 0)] + let (model, _) = makeModel(gateway) + await model.refresh() + + await model.respondToPairing(endpointId: "peer", accepted: false) + + XCTAssertTrue(model.state.pendingPairings.isEmpty) + XCTAssertTrue(model.state.contacts.isEmpty) + XCTAssertEqual(gateway.pairingResponses.map(\.accepted), [false]) + } + + func testAcceptingPairingAddsTheContact() async { + let gateway = FakeCoreGateway() + gateway.pairings = [PendingPairingModel(endpointId: "peer", displayName: "Laptop", receivedAt: 0)] + let (model, _) = makeModel(gateway) + await model.refresh() + gateway.contactsResult = .success([contact("peer", remoteName: "Laptop")]) + + await model.respondToPairing(endpointId: "peer", accepted: true) + + XCTAssertTrue(model.state.pendingPairings.isEmpty) + XCTAssertEqual(model.state.contacts.map(\.endpointId), ["peer"]) + } + + func testForgettingClearsTheSelectionAndReloads() async { + let gateway = FakeCoreGateway() + gateway.contactsResult = .success([contact("peer")]) + let (model, _) = makeModel(gateway) + await model.refresh() + model.select("peer") + + gateway.contactsResult = .success([]) + await model.forget(endpointId: "peer") + + XCTAssertEqual(gateway.forgottenContacts, ["peer"]) + XCTAssertNil(model.state.selectedEndpointId) + XCTAssertTrue(model.state.contacts.isEmpty) + } + + func testBlockingRemovesTheContactAndKeepsItListedAsBlocked() async { + let gateway = FakeCoreGateway() + gateway.contactsResult = .success([contact("peer")]) + let (model, _) = makeModel(gateway) + await model.refresh() + model.select("peer") + + gateway.contactsResult = .success([]) + gateway.blockedResult = .success(["peer"]) + await model.block(endpointId: "peer") + + XCTAssertEqual(gateway.blockedContactIds, ["peer"]) + XCTAssertNil(model.state.selectedEndpointId) + XCTAssertEqual(model.state.blocked, ["peer"]) + } + + /// An empty label clears the override rather than storing whitespace, so the + /// row falls back to the name the device reports. + func testBlankLabelClearsTheLocalName() async { + let gateway = FakeCoreGateway() + let (model, _) = makeModel(gateway) + + await model.setLabel(endpointId: "peer", label: " ") + + XCTAssertEqual(gateway.contactLabels.count, 1) + XCTAssertNil(gateway.contactLabels[0].label) + } + + func testLabelIsTrimmedBeforeStoring() async { + let gateway = FakeCoreGateway() + let (model, _) = makeModel(gateway) + + await model.setLabel(endpointId: "peer", label: " Work Mac ") + + XCTAssertEqual(gateway.contactLabels[0].label, "Work Mac") + } + + /// The core holds the lifetime in memory only, so the stored preference is + /// the durable copy and both have to move together. + func testGrantLifetimeIsPersistedAndPushedToTheCore() async { + let gateway = FakeCoreGateway() + let (model, preferences) = makeModel(gateway) + + model.setGrantLifetime(.days365) + await Task.yield() + + XCTAssertEqual(model.state.grantLifetime, .days365) + XCTAssertEqual(preferences.preferences.grantLifetime, .days365) + XCTAssertEqual(gateway.grantLifetimes.last, .days365) + } + + func testDefaultGrantLifetimeIsNinetyDays() { + let gateway = FakeCoreGateway() + let (model, _) = makeModel(gateway) + + XCTAssertEqual(model.state.grantLifetime, .days90) + } + + /// The local label wins over whatever the peer calls itself. + func testDisplayNamePrefersTheLocalLabel() { + let subject = contact("peer", label: "Work Mac", remoteName: "Totally Not Evil") + + XCTAssertEqual(subject.displayName, "Work Mac") + } + + func testDisplayNameFallsBackToTheReportedName() { + let subject = contact("peer", remoteName: "Laptop") + + XCTAssertEqual(subject.displayName, "Laptop") + } + + func testUnreachableContactIsSurfacedForRepairing() async { + let gateway = FakeCoreGateway() + gateway.contactsResult = .success([contact("peer", canSend: false)]) + let (model, _) = makeModel(gateway) + + await model.refresh() + + XCTAssertEqual(model.state.contacts.first?.canSend, false) + } +} diff --git a/apple/VniDrop/Core/AppPreferences.swift b/apple/VniDrop/Core/AppPreferences.swift index ea62b80..25c761a 100644 --- a/apple/VniDrop/Core/AppPreferences.swift +++ b/apple/VniDrop/Core/AppPreferences.swift @@ -122,6 +122,8 @@ struct AppPreferences: Equatable { var themeMode: ThemeMode var diagnosticsInstallId: String var relayConfiguration: RelayConfiguration + /// Idle lifetime applied to grants this device issues from now on. + var grantLifetime: GrantLifetimeOption } struct AppPreferencesDefaults { @@ -145,6 +147,7 @@ final class AppPreferencesRepository: ObservableObject { static let themeMode = "theme_mode" static let diagnosticsInstallId = "diagnostics_install_id" static let relayConfiguration = "relay_configuration" + static let grantLifetime = "grant_lifetime" } init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) { @@ -158,12 +161,15 @@ final class AppPreferencesRepository: ObservableObject { let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder) let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? "" + let grantLifetime = defaults.string(forKey: Key.grantLifetime) + .flatMap(GrantLifetimeOption.init(rawValue:)) ?? .days90 return AppPreferences( username: username, receiveFolder: folder, themeMode: themeMode, diagnosticsInstallId: installId, - relayConfiguration: resolveRelayConfiguration(defaults) + relayConfiguration: resolveRelayConfiguration(defaults), + grantLifetime: grantLifetime ) } @@ -209,6 +215,11 @@ final class AppPreferencesRepository: ObservableObject { setReceiveFolder(fallback.receiveFolder) } + func setGrantLifetime(_ lifetime: GrantLifetimeOption) { + defaults.set(lifetime.rawValue, forKey: Key.grantLifetime) + reload() + } + func setThemeMode(_ mode: ThemeMode) { defaults.set(mode.rawValue, forKey: Key.themeMode) reload() diff --git a/apple/VniDrop/Features/Contacts/ContactsModel.swift b/apple/VniDrop/Features/Contacts/ContactsModel.swift new file mode 100644 index 0000000..bcc5a38 --- /dev/null +++ b/apple/VniDrop/Features/Contacts/ContactsModel.swift @@ -0,0 +1,212 @@ +import Combine +import Foundation + +struct ContactsState: Equatable { + var contacts: [DeviceContact] = [] + var blocked: [String] = [] + var pendingPairings: [PendingPairingModel] = [] + var pendingOffers: [IncomingOfferModel] = [] + var grantLifetime: GrantLifetimeOption = .days90 + var isLoading = false + /// Endpoints with an in-flight decision, so a row can disable itself without + /// blocking the rest of the list. + var busyEndpoints: Set = [] + var busyOfferIds: Set = [] + var selectedEndpointId: String? + + var selected: DeviceContact? { + guard let selectedEndpointId else { return nil } + return contacts.first { $0.endpointId == selectedEndpointId } + } + + /// One prompt at a time: pairing consent is a modal decision and stacking + /// sheets on top of each other reads as a loop of dialogs. + var currentPairing: PendingPairingModel? { pendingPairings.first } + var currentOffer: IncomingOfferModel? { pendingOffers.first } +} + +/// Drives the device-history surfaces: the list, its detail, and the two +/// consent prompts. Ported in the MVVM shape used by the other feature models. +@MainActor +final class ContactsModel: ObservableObject { + @Published private(set) var state = ContactsState() + + private let repository: CoreGateway + private let messages: UiMessageController + private let preferences: AppPreferencesRepository + private var cancellables = Set() + + init( + repository: CoreGateway, + messages: UiMessageController, + preferences: AppPreferencesRepository + ) { + self.repository = repository + self.messages = messages + self.preferences = preferences + state.grantLifetime = preferences.preferences.grantLifetime + + repository.signals + .sink { [weak self] signal in + guard let self else { return } + switch signal { + case .contactsChanged: + Task { await self.refresh() } + case .offersChanged: + Task { await self.refreshOffers() } + case .approvalChanged, .receiverHistoryChanged, .transfersChanged: + break + } + } + .store(in: &cancellables) + + repository.statePublisher + .map(\.isInitialized) + .removeDuplicates() + .sink { [weak self] isInitialized in + guard let self, isInitialized else { return } + // The core owns the lifetime; push the stored preference on start + // so a restart does not silently fall back to the default. + Task { + await self.repository.setGrantLifetime(self.state.grantLifetime) + await self.refresh() + } + } + .store(in: &cancellables) + } + + // MARK: - Loading + + func refresh() async { + state.isLoading = true + defer { state.isLoading = false } + + switch await repository.contacts() { + case .success(let contacts): + state.contacts = contacts + case .failure(let error): + messages.error(error) + } + if case .success(let blocked) = await repository.blockedContacts() { + state.blocked = blocked + } + state.pendingPairings = await repository.pendingPairings() + await refreshOffers() + } + + func refreshOffers() async { + state.pendingOffers = await repository.pendingOffers() + } + + // MARK: - Selection + + func select(_ endpointId: String?) { state.selectedEndpointId = endpointId } + + // MARK: - Pairing consent + + /// Agree to be reachable by a device, typically right after a transfer. + func allowDeviceToReachMe(endpointId: String, displayName: String?) async { + state.busyEndpoints.insert(endpointId) + defer { state.busyEndpoints.remove(endpointId) } + + if case .failure(let error) = await repository.allowDeviceToReachMe( + endpointId: endpointId, + displayName: displayName + ) { + messages.error(error) + return + } + await refresh() + } + + /// Answer a device's offer to be remembered. + func respondToPairing(endpointId: String, accepted: Bool) async { + state.busyEndpoints.insert(endpointId) + defer { state.busyEndpoints.remove(endpointId) } + + switch await repository.respondToPairing(endpointId: endpointId, accepted: accepted) { + case .success: + // Drop the prompt immediately: the core has already consumed it, and + // leaving it on screen invites a second answer that does nothing. + state.pendingPairings.removeAll { $0.endpointId == endpointId } + if accepted { await refresh() } + case .failure(let error): + messages.error(error) + } + } + + // MARK: - Incoming offers + + /// Answer an incoming offer. Returns the ticket when accepted so the caller + /// can run the receive with a platform-appropriate destination; the core + /// releases it only on acceptance. + func respondToOffer(offerId: String, accepted: Bool) async -> String? { + state.busyOfferIds.insert(offerId) + defer { state.busyOfferIds.remove(offerId) } + + let ticket = await repository.respondToOffer(offerId: offerId, accepted: accepted) + state.pendingOffers.removeAll { $0.offerId == offerId } + return ticket + } + + // MARK: - Management + + func setLabel(endpointId: String, label: String) async { + let trimmed = label.trimmingCharacters(in: .whitespacesAndNewlines) + if case .failure(let error) = await repository.setContactLabel( + endpointId: endpointId, + label: trimmed.isEmpty ? nil : trimmed + ) { + messages.error(error) + return + } + await refresh() + } + + func forget(endpointId: String) async { + state.busyEndpoints.insert(endpointId) + defer { state.busyEndpoints.remove(endpointId) } + + if case .failure(let error) = await repository.forgetContact(endpointId: endpointId) { + messages.error(error) + return + } + if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil } + await refresh() + } + + func forgetAll() async { + if case .failure(let error) = await repository.forgetAllContacts() { + messages.error(error) + return + } + state.selectedEndpointId = nil + await refresh() + } + + func block(endpointId: String) async { + state.busyEndpoints.insert(endpointId) + defer { state.busyEndpoints.remove(endpointId) } + + if case .failure(let error) = await repository.blockContact(endpointId: endpointId) { + messages.error(error) + return + } + if state.selectedEndpointId == endpointId { state.selectedEndpointId = nil } + await refresh() + } + + func unblock(endpointId: String) async { + if case .failure(let error) = await repository.unblockContact(endpointId: endpointId) { + messages.error(error) + return + } + await refresh() + } + + func setGrantLifetime(_ lifetime: GrantLifetimeOption) { + state.grantLifetime = lifetime + preferences.setGrantLifetime(lifetime) + Task { await repository.setGrantLifetime(lifetime) } + } +}