fix: stop the device picker hanging on an offer

Two causes. The connect step had no timeout, so an unreachable device was
retried indefinitely instead of falling through to hold-for-later; it now
gives up after 15s and holds the offer as designed.

The picker also waited on the whole exchange, which includes a person on the
other device deciding — up to two minutes. It now closes on tap and reports
the outcome as a message, and a decline or an unanswered offer is shown as
information rather than an error, since the offer did arrive.
This commit is contained in:
2026-08-07 10:49:32 +02:00
parent 677fc3c6d5
commit 225ff9ad22
15 changed files with 139 additions and 8 deletions

View File

@@ -345,6 +345,14 @@ final class ContactsModel: ObservableObject {
}
}
/// Fire-and-report variant of ``offerTransfer(transferId:to:)``.
///
/// Owned by the model rather than a view so the request survives the picker
/// being dismissed: the answer depends on a person at the other device.
func offerTransferInBackground(transferId: UInt64, to contact: DeviceContact) {
Task { await offerTransfer(transferId: transferId, to: contact) }
}
/// Push an existing transfer to a remembered device.
///
/// Returns whether it landed, so the caller can distinguish "accepted" from
@@ -365,6 +373,15 @@ final class ContactsModel: ObservableObject {
messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh()
return outcome.delivered
case .failure(let error) where error.offerRefusal != nil:
// The offer was delivered and a person said no, or nobody answered.
// Neither is a failure of this device, so neither is shown as one.
let text = error.offerRefusal == .declined
? L10n.Contacts.declinedByDevice(device: contact.displayName)
: L10n.Contacts.noAnswer(device: contact.displayName)
messages.tryShow(UiMessage(text: .dynamic(text), tone: .info))
await refresh()
return false
case .failure(let error):
messages.error(error)
return false

View File

@@ -31,10 +31,12 @@ struct DevicePickerSheet: View {
} else {
List(reachable) { contact in
Button {
Task {
await model.offerTransfer(transferId: transferId, to: contact)
dismiss()
}
// Close first. The other device's user has to accept,
// which can take as long as they take, and holding a
// modal open on someone else's decision reads as a
// hang. The outcome arrives as a message instead.
dismiss()
model.offerTransferInBackground(transferId: transferId, to: contact)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
@@ -49,7 +51,7 @@ struct DevicePickerSheet: View {
}
}
}
.disabled(!model.state.busyEndpoints.isEmpty)
.disabled(model.state.busyEndpoints.contains(contact.endpointId))
}
}
}

View File

@@ -3,6 +3,12 @@ import VnidropCore
/// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
/// How an offered transfer ended without being accepted.
enum OfferRefusal {
case declined
case noAnswer
}
extension Error {
func toUiText() -> UiText {
if let invitation = self as? InvitationError {
@@ -57,6 +63,19 @@ extension Error {
|| haystack.contains("user canceled")
}
/// The other device answered, and the answer was no.
///
/// Not a failure of this device: the offer was delivered and a person
/// declined it, so it is reported as information rather than an error.
var offerRefusal: OfferRefusal? {
let haystack = technicalDetail.lowercased()
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
return .declined
}
if haystack.contains("no-response") { return .noAnswer }
return nil
}
/// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String {
if let vni = self as? VnidropError {