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)
|
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)])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
dismiss()
|
// 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: {
|
} 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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|
||||||
|
|||||||
@@ -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} n’a 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} не ответил"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 n’est joignable pour le moment. Les appareils enregistrés apparaissent ici après un transfert.</string>
|
<string name="contacts_pick_device_empty">Aucun appareil n’est 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 n’a 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user