Files
vnidrop/apple/VniDrop/Features/Receive/ReceiveModel.swift
cdricms 1369df4578 feat(apple): contacts screen and consent prompts
Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
2026-08-06 17:57:54 +02:00

307 lines
9.9 KiB
Swift

import Foundation
import Combine
/// Which acquisition method produced an invitation, ported from `ReceiveMethod`.
enum ReceiveMethod {
case invitationFile
case qrCode
case nfc
/// Pushed by a remembered device and already accepted by the user, so no
/// invitation was acquired by hand.
case offer
}
enum ReceiveHistoryDeleteTarget: Equatable {
case transfer(transferId: UInt64)
case all
}
/// Receive feature state, ported from `feature/receive/ReceiveViewModel.kt`.
struct ReceiveState: Equatable {
var isAcquisitionOpen = false
var ticket = ""
var method: ReceiveMethod?
var inspection: TicketInspectionModel?
var receiverName = ""
var receiveFolder: ReceiveFolder?
var folderAccessStatus: FolderAccessStatus = .unavailable
var isInspecting = false
var isReceiving = false
var activeReceiveTransferId: UInt64?
var lastReceiveError: UiText?
var isWaitingForNfc = false
var historyDeleteTarget: ReceiveHistoryDeleteTarget?
var isDeletingHistory = false
func canReceive(coreInitialized: Bool) -> Bool {
coreInitialized && !ticket.isEmpty && inspection != nil
&& folderAccessStatus == .writable && !isReceiving && !isInspecting
}
static func == (lhs: ReceiveState, rhs: ReceiveState) -> Bool {
lhs.isAcquisitionOpen == rhs.isAcquisitionOpen && lhs.ticket == rhs.ticket
&& lhs.inspection == rhs.inspection && lhs.receiverName == rhs.receiverName
&& lhs.receiveFolder == rhs.receiveFolder && lhs.folderAccessStatus == rhs.folderAccessStatus
&& lhs.isInspecting == rhs.isInspecting && lhs.isReceiving == rhs.isReceiving
&& lhs.activeReceiveTransferId == rhs.activeReceiveTransferId
&& lhs.lastReceiveError == rhs.lastReceiveError && lhs.isWaitingForNfc == rhs.isWaitingForNfc
&& lhs.historyDeleteTarget == rhs.historyDeleteTarget && lhs.isDeletingHistory == rhs.isDeletingHistory
}
}
@MainActor
final class ReceiveModel: ObservableObject {
@Published private(set) var state = ReceiveState()
@Published private(set) var coreState = CoreState()
private let repository: CoreGateway
private let fileSystemService: FileSystemService
private let messages: UiMessageController
private var cancellables = Set<AnyCancellable>()
init(
repository: CoreGateway,
fileSystemService: FileSystemService,
preferences: AppPreferencesRepository,
messages: UiMessageController
) {
self.repository = repository
self.fileSystemService = fileSystemService
self.messages = messages
repository.statePublisher.sink { [weak self] in self?.coreState = $0 }.store(in: &cancellables)
preferences.$preferences
.sink { [weak self] prefs in
guard let self else { return }
Task {
let folder = self.fileSystemService.effectiveReceiveFolder(prefs.receiveFolder)
let status = await self.fileSystemService.validateReceiveFolder(folder)
if self.state.receiverName.isEmpty { self.state.receiverName = prefs.username }
self.state.receiveFolder = folder
self.state.folderAccessStatus = status
}
}
.store(in: &cancellables)
repository.signals
.sink { [weak self] signal in
guard let self else { return }
if case .transfersChanged(let id) = signal {
Task { _ = await self.repository.refresh() }
if self.state.isReceiving && id != 0 {
self.state.activeReceiveTransferId = id
}
}
}
.store(in: &cancellables)
}
func openAcquisition() { state.isAcquisitionOpen = true }
func dismissAcquisition() {
if !state.isReceiving && !state.isInspecting { resetAcquisition() }
}
func setReceiverName(_ value: String) { state.receiverName = value }
func setWaitingForNfc(_ waiting: Bool) { state.isWaitingForNfc = waiting }
func requestDeleteHistoryItem(_ transferId: UInt64) {
let canDelete = coreState.transfers.contains {
$0.transferId == transferId && $0.direction == .receive && $0.status.isTerminalReceiveHistory
}
if canDelete { state.historyDeleteTarget = .transfer(transferId: transferId) }
}
func requestClearHistory() {
if coreState.transfers.contains(where: { $0.direction == .receive && $0.status.isTerminalReceiveHistory }) {
state.historyDeleteTarget = .all
}
}
func dismissHistoryDelete() {
if !state.isDeletingHistory { state.historyDeleteTarget = nil }
}
func confirmHistoryDelete() {
guard let target = state.historyDeleteTarget, !state.isDeletingHistory else { return }
state.isDeletingHistory = true
// Close synchronously: the alert's dismiss binding runs async and no-ops while
// `isDeletingHistory`, which would otherwise leave the target set and macOS
// re-present it.
state.historyDeleteTarget = nil
Task {
let result: Result<Void, Error>
switch target {
case .transfer(let id):
result = await repository.delete(transferId: id)
case .all:
result = (await repository.clearReceiveHistory()).map { _ in () }
}
switch result {
case .success:
state.historyDeleteTarget = nil
state.isDeletingHistory = false
let key = target == .all ? L10n.Receive.historyCleared : L10n.Transfer.deleted
messages.tryShow(UiMessage(text: .resource(key), tone: .success))
case .failure(let error):
state.isDeletingHistory = false
messages.error(error)
}
}
}
func onInvitationResult(_ method: ReceiveMethod, _ result: Result<String, Error>) {
state.isWaitingForNfc = false
switch result {
case .success(let raw): inspectInvitation(method, raw)
case .failure(let error): messages.error(error)
}
}
/// Receive a transfer the user has already accepted in the offer prompt.
///
/// The consent happened in that prompt, so this does not ask again: it
/// inspects the ticket and starts, falling back to the ordinary review sheet
/// only when the destination is not usable and the user has to fix it.
func receiveOffered(ticket: String) {
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.ticket = trimmed
state.method = .offer
state.inspection = nil
state.isInspecting = true
Task {
switch await repository.inspectTicket(trimmed) {
case .success(let inspection):
state.inspection = inspection
state.isInspecting = false
if state.canReceive(coreInitialized: coreState.isInitialized) {
receive()
} else {
// Usually a missing or unwritable destination: show the review
// sheet so the user can point it somewhere valid.
state.isAcquisitionOpen = true
}
case .failure(let error):
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
messages.error(error)
}
}
}
func receive() {
let current = state
guard let folder = current.receiveFolder else { return }
if !current.canReceive(coreInitialized: coreState.isInitialized) { return }
state.isReceiving = true
state.lastReceiveError = nil
state.activeReceiveTransferId = nil
Task {
let result: Result<Void, Error>
if folder.kind == .iosSecurityScopedUrl {
result = await repository.receiveIntoSecurityScopedDirectory(
ticket: current.ticket, outputDirectoryUrl: folder.value, receiverName: current.receiverName
)
} else {
result = await repository.receive(
ticket: current.ticket, outputDir: folder.value, receiverName: current.receiverName
)
}
switch result {
case .success:
resetAcquisition()
let canReveal = fileSystemService.canRevealReceiveFolder(folder)
messages.tryShow(UiMessage(
text: .resource(L10n.Receive.completed),
tone: .success,
actionLabel: canReveal ? .resource(L10n.Button.showInFiles) : nil,
onAction: canReveal ? { self.revealReceiveFolder(folder) } : nil
))
case .failure(let error):
if error.isUserCancellation {
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
return
}
let uiText = error.toUiText()
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = uiText
messages.tryShow(UiMessage(
text: uiText,
tone: .error,
actionLabel: .resource(L10n.Button.retry),
onAction: { self.receive() }
))
}
}
}
func cancelActiveReceive() {
let transferId = state.activeReceiveTransferId
?? coreState.transfers.first { $0.direction == .receive && $0.status == .receiving }?.transferId
?? coreState.events.first { $0.eventDirection == .receive && $0.transferId != nil }?.transferId
guard let transferId else { return }
Task {
let result = await repository.cancel(transferId: transferId)
switch result {
case .success:
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
_ = await repository.refresh()
case .failure(let error):
messages.error(error)
}
}
}
private func revealReceiveFolder(_ folder: ReceiveFolder) {
Task {
let result = await fileSystemService.revealReceiveFolder(folder)
if case .failure = result {
messages.show(UiMessage(text: .resource(L10n.Receive.openFilesFailed), tone: .error))
}
}
}
private func inspectInvitation(_ method: ReceiveMethod, _ raw: String) {
let ticket = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ticket.isEmpty { return messages.error(.resource(L10n.Error.invitationEmpty)) }
state.isAcquisitionOpen = true
state.ticket = ticket
state.method = method
state.inspection = nil
state.isInspecting = true
Task {
let result = await repository.inspectTicket(ticket)
switch result {
case .success(let inspection):
state.inspection = inspection
state.isInspecting = false
case .failure(let error):
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
messages.error(error)
}
}
}
private func resetAcquisition() {
state.isAcquisitionOpen = false
state.ticket = ""
state.method = nil
state.inspection = nil
state.isInspecting = false
state.isReceiving = false
state.activeReceiveTransferId = nil
state.lastReceiveError = nil
state.isWaitingForNfc = false
}
}