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

@@ -414,6 +414,35 @@ final class ContactsModelTests: XCTestCase {
XCTAssertFalse(delivered) 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 { func testUnreachableContactIsSurfacedForRepairing() async {
let gateway = FakeCoreGateway() let gateway = FakeCoreGateway()
gateway.contactsResult = .success([contact("peer", canSend: false)]) gateway.contactsResult = .success([contact("peer", canSend: false)])

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. /// Push an existing transfer to a remembered device.
/// ///
/// Returns whether it landed, so the caller can distinguish "accepted" from /// 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)) messages.tryShow(UiMessage(text: text, tone: outcome.delivered ? .success : .info))
await refresh() await refresh()
return outcome.delivered 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): case .failure(let error):
messages.error(error) messages.error(error)
return false return false

View File

@@ -31,10 +31,12 @@ struct DevicePickerSheet: View {
} else { } else {
List(reachable) { contact in List(reachable) { contact in
Button { Button {
Task { // Close first. The other device's user has to accept,
await model.offerTransfer(transferId: transferId, to: contact) // 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() dismiss()
} model.offerTransferInBackground(transferId: transferId, to: contact)
} label: { } label: {
HStack { HStack {
VStack(alignment: .leading, spacing: 2) { 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 /// Maps technical failures to stable, user-facing catalog keys. Ported from
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs. /// `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 { extension Error {
func toUiText() -> UiText { func toUiText() -> UiText {
if let invitation = self as? InvitationError { if let invitation = self as? InvitationError {
@@ -57,6 +63,19 @@ extension Error {
|| haystack.contains("user canceled") || 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. /// Prefers a `VnidropError` reason; else the localized description.
var technicalDetail: String { var technicalDetail: String {
if let vni = self as? VnidropError { if let vni = self as? VnidropError {

View File

@@ -4,7 +4,7 @@
//! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI //! [`crate::pairing`]; this is where those meet the endpoint and the UniFFI
//! surface. //! surface.
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use iroh::{EndpointAddr, EndpointId}; use iroh::{EndpointAddr, EndpointId};
@@ -27,6 +27,12 @@ use crate::{
util::now_ms, util::now_ms,
}; };
/// How long to wait for a device to answer before treating it as not running.
///
/// Without this an offline peer never fails, it just keeps being retried, and
/// the offer is never handed to the hold-for-later path.
const OFFER_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
/// Whether a device may be polled again yet. /// Whether a device may be polled again yet.
/// ///
/// Split out because the surrounding call needs two live nodes to exercise, /// Split out because the surrounding call needs two live nodes to exercise,
@@ -419,9 +425,9 @@ impl CoreInner {
) -> Result<OfferResponse> { ) -> Result<OfferResponse> {
let addr = self.contact_addr(endpoint_id).await?; let addr = self.contact_addr(endpoint_id).await?;
let client = OfferService::client(self.endpoint.clone(), addr); let client = OfferService::client(self.endpoint.clone(), addr);
let challenge = client let challenge = tokio::time::timeout(OFFER_CONNECT_TIMEOUT, client.request_challenge())
.request_challenge()
.await .await
.map_err(|_| VnidropError::transfer(anyhow::anyhow!("device did not answer in time")))?
.context("device is not reachable") .context("device is not reachable")
.map_err(VnidropError::transfer)?; .map_err(VnidropError::transfer)?;

View File

@@ -5284,6 +5284,46 @@
"nl": "{device} heeft de overdracht geaccepteerd", "nl": "{device} heeft de overdracht geaccepteerd",
"ru": "{device} принял передачу" "ru": "{device} принял передачу"
} }
},
"contacts_declined_by_device": {
"context": "Shown when the person on the other device declined an offered transfer. {device} = device name.",
"args": [
{
"name": "device",
"type": "string"
}
],
"translations": {
"en": "{device} declined the transfer",
"fr": "{device} a refusé le transfert",
"es": "{device} rechazó la transferencia",
"it": "{device} ha rifiutato il trasferimento",
"de": "{device} hat die Übertragung abgelehnt",
"pt": "{device} recusou a transferência",
"pl": "{device} odrzuciło przesyłkę",
"nl": "{device} heeft de overdracht geweigerd",
"ru": "{device} отклонил передачу"
}
},
"contacts_no_answer": {
"context": "Shown when an offered transfer got no answer on the other device before timing out. {device} = device name.",
"args": [
{
"name": "device",
"type": "string"
}
],
"translations": {
"en": "{device} did not answer",
"fr": "{device} na pas répondu",
"es": "{device} no respondió",
"it": "{device} non ha risposto",
"de": "{device} hat nicht geantwortet",
"pt": "{device} não respondeu",
"pl": "{device} nie odpowiedziało",
"nl": "{device} heeft niet geantwoord",
"ru": "{device} не ответил"
}
} }
} }
} }

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Gerät auswählen</string> <string name="contacts_pick_device_title">Gerät auswählen</string>
<string name="contacts_pick_device_empty">Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.</string> <string name="contacts_pick_device_empty">Derzeit ist kein Gerät erreichbar. Gespeicherte Geräte erscheinen hier nach einer Übertragung.</string>
<string name="contacts_sent_to_device">%1$s hat die Übertragung angenommen</string> <string name="contacts_sent_to_device">%1$s hat die Übertragung angenommen</string>
<string name="contacts_declined_by_device">%1$s hat die Übertragung abgelehnt</string>
<string name="contacts_no_answer">%1$s hat nicht geantwortet</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d Datei</item> <item quantity="one">%1$d Datei</item>
<item quantity="other">%1$d Dateien</item> <item quantity="other">%1$d Dateien</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Elegir un dispositivo</string> <string name="contacts_pick_device_title">Elegir un dispositivo</string>
<string name="contacts_pick_device_empty">Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.</string> <string name="contacts_pick_device_empty">Ningún dispositivo está disponible ahora. Los dispositivos guardados aparecen aquí tras una transferencia.</string>
<string name="contacts_sent_to_device">%1$s aceptó la transferencia</string> <string name="contacts_sent_to_device">%1$s aceptó la transferencia</string>
<string name="contacts_declined_by_device">%1$s rechazó la transferencia</string>
<string name="contacts_no_answer">%1$s no respondió</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d archivo</item> <item quantity="one">%1$d archivo</item>
<item quantity="other">%1$d archivos</item> <item quantity="other">%1$d archivos</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Choisir un appareil</string> <string name="contacts_pick_device_title">Choisir un appareil</string>
<string name="contacts_pick_device_empty">Aucun appareil nest joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.</string> <string name="contacts_pick_device_empty">Aucun appareil nest joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.</string>
<string name="contacts_sent_to_device">%1$s a accepté le transfert</string> <string name="contacts_sent_to_device">%1$s a accepté le transfert</string>
<string name="contacts_declined_by_device">%1$s a refusé le transfert</string>
<string name="contacts_no_answer">%1$s na pas répondu</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d fichier</item> <item quantity="one">%1$d fichier</item>
<item quantity="other">%1$d fichiers</item> <item quantity="other">%1$d fichiers</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Scegli un dispositivo</string> <string name="contacts_pick_device_title">Scegli un dispositivo</string>
<string name="contacts_pick_device_empty">Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.</string> <string name="contacts_pick_device_empty">Nessun dispositivo è raggiungibile ora. I dispositivi memorizzati compaiono qui dopo un trasferimento.</string>
<string name="contacts_sent_to_device">%1$s ha accettato il trasferimento</string> <string name="contacts_sent_to_device">%1$s ha accettato il trasferimento</string>
<string name="contacts_declined_by_device">%1$s ha rifiutato il trasferimento</string>
<string name="contacts_no_answer">%1$s non ha risposto</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>
<item quantity="other">%1$d file</item> <item quantity="other">%1$d file</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Kies een apparaat</string> <string name="contacts_pick_device_title">Kies een apparaat</string>
<string name="contacts_pick_device_empty">Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.</string> <string name="contacts_pick_device_empty">Er is nu geen apparaat bereikbaar. Onthouden apparaten verschijnen hier na een overdracht.</string>
<string name="contacts_sent_to_device">%1$s heeft de overdracht geaccepteerd</string> <string name="contacts_sent_to_device">%1$s heeft de overdracht geaccepteerd</string>
<string name="contacts_declined_by_device">%1$s heeft de overdracht geweigerd</string>
<string name="contacts_no_answer">%1$s heeft niet geantwoord</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d bestand</item> <item quantity="one">%1$d bestand</item>
<item quantity="other">%1$d bestanden</item> <item quantity="other">%1$d bestanden</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Wybierz urządzenie</string> <string name="contacts_pick_device_title">Wybierz urządzenie</string>
<string name="contacts_pick_device_empty">Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.</string> <string name="contacts_pick_device_empty">Żadne urządzenie nie jest teraz dostępne. Zapamiętane urządzenia pojawią się tu po przesłaniu.</string>
<string name="contacts_sent_to_device">%1$s zaakceptowało przesyłkę</string> <string name="contacts_sent_to_device">%1$s zaakceptowało przesyłkę</string>
<string name="contacts_declined_by_device">%1$s odrzuciło przesyłkę</string>
<string name="contacts_no_answer">%1$s nie odpowiedziało</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d plik</item> <item quantity="one">%1$d plik</item>
<item quantity="few">%1$d pliki</item> <item quantity="few">%1$d pliki</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Escolher um dispositivo</string> <string name="contacts_pick_device_title">Escolher um dispositivo</string>
<string name="contacts_pick_device_empty">Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.</string> <string name="contacts_pick_device_empty">Nenhum dispositivo está acessível agora. Os dispositivos guardados aparecem aqui após uma transferência.</string>
<string name="contacts_sent_to_device">%1$s aceitou a transferência</string> <string name="contacts_sent_to_device">%1$s aceitou a transferência</string>
<string name="contacts_declined_by_device">%1$s recusou a transferência</string>
<string name="contacts_no_answer">%1$s não respondeu</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d ficheiro</item> <item quantity="one">%1$d ficheiro</item>
<item quantity="other">%1$d ficheiros</item> <item quantity="other">%1$d ficheiros</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Выберите устройство</string> <string name="contacts_pick_device_title">Выберите устройство</string>
<string name="contacts_pick_device_empty">Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи.</string> <string name="contacts_pick_device_empty">Сейчас ни одно устройство недоступно. Сохранённые устройства появятся здесь после передачи.</string>
<string name="contacts_sent_to_device">%1$s принял передачу</string> <string name="contacts_sent_to_device">%1$s принял передачу</string>
<string name="contacts_declined_by_device">%1$s отклонил передачу</string>
<string name="contacts_no_answer">%1$s не ответил</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="one">%1$d файл</item> <item quantity="one">%1$d файл</item>
<item quantity="few">%1$d файла</item> <item quantity="few">%1$d файла</item>

View File

@@ -348,6 +348,8 @@
<string name="contacts_pick_device_title">Choose a device</string> <string name="contacts_pick_device_title">Choose a device</string>
<string name="contacts_pick_device_empty">No device can be reached right now. Remembered devices appear here after a transfer.</string> <string name="contacts_pick_device_empty">No device can be reached right now. Remembered devices appear here after a transfer.</string>
<string name="contacts_sent_to_device">%1$s accepted the transfer</string> <string name="contacts_sent_to_device">%1$s accepted the transfer</string>
<string name="contacts_declined_by_device">%1$s declined the transfer</string>
<string name="contacts_no_answer">%1$s did not answer</string>
<plurals name="transfer_file_count"> <plurals name="transfer_file_count">
<item quantity="other">%1$d files</item> <item quantity="other">%1$d files</item>
<item quantity="one">%1$d file</item> <item quantity="one">%1$d file</item>