mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
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:
@@ -414,6 +414,35 @@ final class ContactsModelTests: XCTestCase {
|
||||
XCTAssertFalse(delivered)
|
||||
}
|
||||
|
||||
/// A refusal by the person on the other device is information, not an error.
|
||||
func testADeclinedOfferIsReportedWithoutAnErrorTone() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.offerTransferResult = .failure(
|
||||
InvitationError.raw("permission error: device did not accept the transfer: receiver-declined")
|
||||
)
|
||||
let defaults = UserDefaults(suiteName: "contacts-declined-\(UUID().uuidString)")!
|
||||
let preferences = AppPreferencesRepository(
|
||||
defaults: defaults,
|
||||
fallback: AppPreferencesDefaults(
|
||||
username: "tester",
|
||||
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
|
||||
themeMode: .system
|
||||
)
|
||||
)
|
||||
let messages = UiMessageController()
|
||||
let model = ContactsModel(
|
||||
repository: gateway,
|
||||
messages: messages,
|
||||
preferences: preferences,
|
||||
fileSystemService: FakeFileSystemService()
|
||||
)
|
||||
|
||||
let delivered = await model.offerTransfer(transferId: 7, to: contact("peer"))
|
||||
|
||||
XCTAssertFalse(delivered)
|
||||
XCTAssertEqual(messages.current?.tone, .info)
|
||||
}
|
||||
|
||||
func testUnreachableContactIsSurfacedForRepairing() async {
|
||||
let gateway = FakeCoreGateway()
|
||||
gateway.contactsResult = .success([contact("peer", canSend: false)])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user