diff --git a/apple/VniDrop/Core/CoreModels.swift b/apple/VniDrop/Core/CoreModels.swift index 33fe647..c2cda88 100644 --- a/apple/VniDrop/Core/CoreModels.swift +++ b/apple/VniDrop/Core/CoreModels.swift @@ -88,6 +88,7 @@ enum ReceiverDeliveryStatus: Equatable, Sendable { case refused case expired case completed + case failed case unknown } diff --git a/apple/VniDrop/Core/CoreRepository.swift b/apple/VniDrop/Core/CoreRepository.swift index d98e969..e7751ad 100644 --- a/apple/VniDrop/Core/CoreRepository.swift +++ b/apple/VniDrop/Core/CoreRepository.swift @@ -512,6 +512,7 @@ private extension ReceiverRequest { case "refused": return .refused case "expired": return .expired case "completed": return .completed + case "failed": return .failed default: return .unknown } } diff --git a/apple/VniDrop/Features/Send/TransferDetailsView.swift b/apple/VniDrop/Features/Send/TransferDetailsView.swift index 6b046b0..2145154 100644 --- a/apple/VniDrop/Features/Send/TransferDetailsView.swift +++ b/apple/VniDrop/Features/Send/TransferDetailsView.swift @@ -237,6 +237,7 @@ private struct ReceiverRow: View { let name = receiver.receiverName ?? receiver.receiverDeviceName ?? String(localized: "transfer_nearby_device") let showLive = sendProgress != nil && receiver.status != .completed && receiver.status != .refused && receiver.status != .expired + && receiver.status != .failed HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 6) { Text(name).font(VniType.bodyLarge).lineLimit(1) @@ -397,6 +398,7 @@ extension ReceiverDeliveryStatus { case .refused: return "transfer_receiver_refused" case .expired: return "transfer_receiver_expired" case .completed: return "transfer_receiver_completed" + case .failed: return "transfer_receiver_failed" case .unknown: return "transfer_receiver_unknown" } } @@ -404,7 +406,7 @@ extension ReceiverDeliveryStatus { func statusColor(_ colors: VniDropColors) -> Color { switch self { case .completed: return colors.brandDefault - case .refused, .expired: return colors.destructiveDefault + case .refused, .expired, .failed: return colors.destructiveDefault default: return colors.foregroundLighter } } diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index f9e018b..1d0b86e 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -16581,6 +16581,66 @@ } } }, + "transfer_receiver_failed": { + "comment": "Receiver status: the transfer failed.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Übertragung fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Transfer failed" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Transferencia fallida" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Transfert échoué" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Trasferimento non riuscito" + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Overdracht mislukt" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Przesyłanie nie powiodło się" + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "A transferência falhou" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Передача не удалась" + } + } + } + }, "transfer_receiver_expired": { "comment": "Receiver status: the request expired.", "extractionState": "manual", diff --git a/crates/vnidrop/src/approval.rs b/crates/vnidrop/src/approval.rs index 23075f9..c0f273d 100644 --- a/crates/vnidrop/src/approval.rs +++ b/crates/vnidrop/src/approval.rs @@ -8,7 +8,10 @@ use uuid::Uuid; use crate::{ access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS}, event_hub::EventHub, - handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, + handshake::{ + DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, + RequestTransfer, + }, repository::{ReceiverRequestInsert, Repository}, transfer_state::ReceiverRequestStatus, util::now_ms, @@ -70,6 +73,46 @@ impl ApprovalService { } } + pub(crate) async fn fail_delivery( + &self, + remote_endpoint_id: String, + receipt: DeliveryFailureReceipt, + ) -> DeliveryReceiptResponse { + let token_hash = receipt_token_hash(&receipt.token); + match self + .repository + .fail_receiver_delivery( + &receipt.request_id, + receipt.transfer_id, + &remote_endpoint_id, + &token_hash, + &receipt.reason, + ) + .await + { + Ok(()) => { + self.event_hub.emit_transfer( + receipt.transfer_id, + "send", + "delivery", + "receiver-failed", + json!({ + "request_id": receipt.request_id, + "remote_endpoint_id": remote_endpoint_id, + "reason": receipt.reason, + }), + ); + DeliveryReceiptResponse::Recorded + } + Err(error) => { + tracing::warn!(%error, "rejected receiver delivery failure"); + DeliveryReceiptResponse::Rejected { + reason: "invalid-receipt".to_string(), + } + } + } + } + pub(crate) fn new( repository: Repository, event_hub: Arc, diff --git a/crates/vnidrop/src/handshake.rs b/crates/vnidrop/src/handshake.rs index 385a220..3cc7638 100644 --- a/crates/vnidrop/src/handshake.rs +++ b/crates/vnidrop/src/handshake.rs @@ -72,6 +72,14 @@ impl ProtocolHandler for HandshakeService { .await; let _ = tx.send(response).await; } + HandshakeMessage::ReportDeliveryFailure(message) => { + let WithChannels { inner, tx, .. } = message; + let response = self + .approval + .fail_delivery(remote_endpoint_id.clone(), inner) + .await; + let _ = tx.send(response).await; + } } } @@ -109,6 +117,13 @@ impl HandshakeClient { ) -> Result { self.inner.rpc(receipt).await } + + pub(crate) async fn report_delivery_failure( + &self, + receipt: DeliveryFailureReceipt, + ) -> Result { + self.inner.rpc(receipt).await + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -140,6 +155,14 @@ pub(crate) struct DeliveryReceipt { pub(crate) token: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct DeliveryFailureReceipt { + pub(crate) request_id: String, + pub(crate) transfer_id: u64, + pub(crate) token: String, + pub(crate) reason: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub(crate) enum DeliveryReceiptResponse { Recorded, @@ -153,4 +176,6 @@ enum HandshakeProtocol { RequestTransfer(RequestTransfer), #[rpc(tx=oneshot::Sender)] ReportDelivery(DeliveryReceipt), + #[rpc(tx=oneshot::Sender)] + ReportDeliveryFailure(DeliveryFailureReceipt), } diff --git a/crates/vnidrop/src/repository.rs b/crates/vnidrop/src/repository.rs index e493888..b913e8e 100644 --- a/crates/vnidrop/src/repository.rs +++ b/crates/vnidrop/src/repository.rs @@ -20,7 +20,7 @@ use crate::{ util::now_ms, }; -const SCHEMA_VERSION: i64 = 6; +const SCHEMA_VERSION: i64 = 7; #[derive(Debug, Clone)] pub(crate) struct Repository { @@ -86,6 +86,7 @@ pub(crate) struct PendingDeliveryReceipt { pub(crate) request_id: String, pub(crate) sender_transfer_id: u64, pub(crate) token: String, + pub(crate) failure_reason: Option, } pub(crate) struct PendingDeliveryReceiptInsert<'a> { @@ -94,6 +95,7 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> { pub(crate) request_id: &'a str, pub(crate) sender_transfer_id: u64, pub(crate) token: &'a str, + pub(crate) failure_reason: Option<&'a str>, } impl Repository { @@ -294,12 +296,24 @@ impl Repository { sender_blob_ticket TEXT NOT NULL, sender_transfer_id INTEGER NOT NULL, token TEXT NOT NULL, + failure_reason TEXT, created_at INTEGER NOT NULL ); "#, ) .execute(&self.pool) .await?; + let receipt_columns = sqlx::query("PRAGMA table_info(pending_delivery_receipts)") + .fetch_all(&self.pool) + .await?; + if !receipt_columns + .iter() + .any(|row| row.get::(1) == "failure_reason") + { + sqlx::query("ALTER TABLE pending_delivery_receipts ADD COLUMN failure_reason TEXT") + .execute(&self.pool) + .await?; + } sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}")) .execute(&self.pool) @@ -561,13 +575,14 @@ impl Repository { r#" INSERT INTO pending_delivery_receipts ( request_id, local_transfer_id, sender_blob_ticket, - sender_transfer_id, token, created_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + sender_transfer_id, token, failure_reason, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(request_id) DO UPDATE SET local_transfer_id = excluded.local_transfer_id, sender_blob_ticket = excluded.sender_blob_ticket, sender_transfer_id = excluded.sender_transfer_id, - token = excluded.token + token = excluded.token, + failure_reason = excluded.failure_reason "#, ) .bind(receipt.request_id) @@ -575,6 +590,7 @@ impl Repository { .bind(receipt.sender_blob_ticket) .bind(to_db_id(receipt.sender_transfer_id)?) .bind(receipt.token) + .bind(receipt.failure_reason) .bind(now_ms()) .execute(&mut *transaction) .await?; @@ -582,13 +598,46 @@ impl Repository { Ok(()) } + pub(crate) async fn queue_failed_delivery_receipt( + &self, + receipt: PendingDeliveryReceiptInsert<'_>, + ) -> Result<()> { + let Some(reason) = receipt.failure_reason else { + anyhow::bail!("failed delivery receipt requires a reason"); + }; + sqlx::query( + r#" + INSERT INTO pending_delivery_receipts ( + request_id, local_transfer_id, sender_blob_ticket, + sender_transfer_id, token, failure_reason, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(request_id) DO UPDATE SET + local_transfer_id = excluded.local_transfer_id, + sender_blob_ticket = excluded.sender_blob_ticket, + sender_transfer_id = excluded.sender_transfer_id, + token = excluded.token, + failure_reason = excluded.failure_reason + "#, + ) + .bind(receipt.request_id) + .bind(to_db_id(receipt.local_transfer_id)?) + .bind(receipt.sender_blob_ticket) + .bind(to_db_id(receipt.sender_transfer_id)?) + .bind(receipt.token) + .bind(reason) + .bind(now_ms()) + .execute(&self.pool) + .await?; + Ok(()) + } + pub(crate) async fn list_pending_delivery_receipts( &self, ) -> Result> { let rows = sqlx::query( r#" SELECT local_transfer_id, sender_blob_ticket, request_id, - sender_transfer_id, token + sender_transfer_id, token, failure_reason FROM pending_delivery_receipts ORDER BY created_at ASC "#, @@ -603,6 +652,7 @@ impl Repository { request_id: row.get("request_id"), sender_transfer_id: row.get::("sender_transfer_id") as u64, token: row.get("token"), + failure_reason: row.get("failure_reason"), }) .collect()) } @@ -883,6 +933,56 @@ impl Repository { } } + pub(crate) async fn fail_receiver_delivery( + &self, + id: &str, + transfer_id: u64, + remote_endpoint_id: &str, + token_hash: &str, + reason: &str, + ) -> Result<()> { + let result = sqlx::query( + r#" + UPDATE receiver_requests + SET status = 'failed', reason = ?1 + WHERE id = ?2 AND transfer_id = ?3 AND remote_endpoint_id = ?4 AND receipt_token_hash = ?5 + AND status = 'accepted' + "#, + ) + .bind(reason) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .execute(&self.pool) + .await?; + if result.rows_affected() == 1 { + return Ok(()); + } + let already_recorded = sqlx::query( + r#" + SELECT EXISTS( + SELECT 1 FROM receiver_requests + WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3 + AND receipt_token_hash = ?4 AND status = 'failed' + ) + "#, + ) + .bind(id) + .bind(to_db_id(transfer_id)?) + .bind(remote_endpoint_id) + .bind(token_hash) + .fetch_one(&self.pool) + .await? + .get::(0) + != 0; + if already_recorded { + Ok(()) + } else { + anyhow::bail!("delivery failure did not match an accepted receiver request") + } + } + pub(crate) async fn expire_pending_receiver_requests(&self, reason: &str) -> Result { let result = sqlx::query( r#" diff --git a/crates/vnidrop/src/runtime/delivery.rs b/crates/vnidrop/src/runtime/delivery.rs index 8674480..8938e2c 100644 --- a/crates/vnidrop/src/runtime/delivery.rs +++ b/crates/vnidrop/src/runtime/delivery.rs @@ -4,7 +4,9 @@ use serde_json::json; use super::{filter_peer_addr_for_relay_mode, CoreInner}; use crate::{ - handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeService}, + handshake::{ + DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService, + }, repository::PendingDeliveryReceipt, ticket::parse_persisted_sender_address, }; @@ -96,13 +98,29 @@ impl CoreInner { } }; let client = HandshakeService::client(self.endpoint.clone(), sender_addr); - let receipt = DeliveryReceipt { - request_id: pending.request_id.clone(), - transfer_id: pending.sender_transfer_id, - token: pending.token, - }; - match tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, client.report_delivery(receipt)).await - { + let request_id = pending.request_id.clone(); + let response = tokio::time::timeout(DELIVERY_RECEIPT_TIMEOUT, async { + if let Some(reason) = pending.failure_reason { + client + .report_delivery_failure(DeliveryFailureReceipt { + request_id, + transfer_id: pending.sender_transfer_id, + token: pending.token, + reason, + }) + .await + } else { + client + .report_delivery(DeliveryReceipt { + request_id, + transfer_id: pending.sender_transfer_id, + token: pending.token, + }) + .await + } + }) + .await; + match response { Ok(Ok(DeliveryReceiptResponse::Recorded)) => { if let Err(error) = self .repository diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index 9261bbb..d4b5671 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -1,7 +1,7 @@ use std::{ io, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, Mutex}, }; use anyhow::{Context, Result}; @@ -222,6 +222,10 @@ impl CoreInner { self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) .await .map_err(VnidropError::repository)?; + let persisted_sender_address = + encode_persisted_sender_address(parsed.blob_ticket.addr()) + .context("failed to encode sender address for delivery receipt")?; + let delivery_receipt = Arc::new(Mutex::new(None)); // Cancellation is cooperative: it stops our receive future and marks // local state while lower-level Iroh work unwinds naturally. let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); @@ -237,7 +241,14 @@ impl CoreInner { ); let (result, cancelled) = tokio::select! { - result = self.receive_inner(transfer_id, parsed, target, receiver_name) => { + result = self.receive_inner( + transfer_id, + parsed, + target, + receiver_name, + persisted_sender_address.clone(), + delivery_receipt.clone(), + ) => { (result.map_err(VnidropError::transfer), false) }, _ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true), @@ -267,6 +278,34 @@ impl CoreInner { ) .await; } + let receipt = delivery_receipt.lock().expect("delivery_receipt").take(); + if let Some(receipt) = receipt { + let reason = if cancelled { "cancelled" } else { error.code() }; + match self + .repository + .queue_failed_delivery_receipt(PendingDeliveryReceiptInsert { + local_transfer_id: transfer_id, + sender_blob_ticket: &persisted_sender_address, + request_id: &receipt.request_id, + sender_transfer_id: receipt.transfer_id, + token: &receipt.token, + failure_reason: Some(reason), + }) + .await + { + Ok(()) => self.delivery_receipt_notify.notify_one(), + Err(queue_error) => { + tracing::warn!(%queue_error, "failed to queue delivery failure receipt"); + self.emit_transfer( + transfer_id, + "receive", + "delivery", + "receipt-failed", + json!({ "reason": queue_error.to_string() }), + ); + } + } + } } result.map_err(anyhow::Error::new) } @@ -277,6 +316,8 @@ impl CoreInner { parsed: ParsedTransferTicket, target: ReceiveTarget, receiver_name: Option, + persisted_sender_address: String, + pending_delivery_receipt: Arc>>, ) -> Result<()> { if let ReceiveTarget::Directory(output_dir) = &target { tokio::fs::create_dir_all(output_dir) @@ -284,8 +325,6 @@ impl CoreInner { .map_err(VnidropError::filesystem)?; } let sender_addr = parsed.blob_ticket.addr().clone(); - let persisted_sender_address = encode_persisted_sender_address(&sender_addr) - .context("failed to encode sender address for delivery receipt")?; self.emit_transfer(transfer_id, "receive", "network", "connecting", json!({})); // Every VniDrop ticket carries metadata and must complete the handshake. @@ -297,6 +336,9 @@ impl CoreInner { receiver_name.as_deref(), ) .await?; + *pending_delivery_receipt + .lock() + .expect("pending_delivery_receipt") = Some(delivery_receipt.clone()); let connection = self .endpoint .connect(sender_addr.clone(), iroh_blobs::ALPN) @@ -373,9 +415,14 @@ impl CoreInner { request_id: &delivery_receipt.request_id, sender_transfer_id: delivery_receipt.transfer_id, token: &delivery_receipt.token, + failure_reason: None, }) .await .map_err(VnidropError::repository)?; + pending_delivery_receipt + .lock() + .expect("pending_delivery_receipt") + .take(); drop(download_tag); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.delivery_receipt_notify.notify_one(); diff --git a/crates/vnidrop/src/tests/repository.rs b/crates/vnidrop/src/tests/repository.rs index eaa1b11..dfe9222 100644 --- a/crates/vnidrop/src/tests/repository.rs +++ b/crates/vnidrop/src/tests/repository.rs @@ -66,7 +66,7 @@ async fn received_artifacts_survive_history_deletion() { async fn persists_transfers_and_events_across_reopen() { let temp = tempfile::tempdir().unwrap(); let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 6); + assert_eq!(repository.schema_version().await.unwrap(), 7); repository .insert_transfer(transfer( 7, @@ -135,6 +135,7 @@ async fn receive_completion_persists_delivery_receipt_until_recorded() { request_id: "request-93", sender_transfer_id: 39, token: "receipt-token", + failure_reason: None, }) .await .unwrap(); @@ -221,6 +222,68 @@ async fn receiver_request_can_only_be_resolved_once() { assert!(requests[0].completed_at.is_some()); } +#[tokio::test] +async fn authenticated_delivery_failure_marks_accepted_receiver_failed() { + let temp = tempfile::tempdir().unwrap(); + let repository = Repository::open(temp.path()).await.unwrap(); + repository + .insert_receiver_request(ReceiverRequestInsert { + id: "request-failed", + transfer_id: 78, + remote_endpoint_id: "node-a", + transfer_name: "demo", + receiver_name: Some("receiver"), + receiver_device_name: None, + app_version: "0.1.0", + }) + .await + .unwrap(); + repository + .update_receiver_request_status("request-failed", ReceiverRequestStatus::Accepted, None) + .await + .unwrap(); + repository + .set_receiver_receipt_token("request-failed", "token-hash") + .await + .unwrap(); + + repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-a", + "token-hash", + "destination_exists", + ) + .await + .unwrap(); + repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-a", + "token-hash", + "destination_exists", + ) + .await + .unwrap(); + assert!(repository + .fail_receiver_delivery( + "request-failed", + 78, + "node-b", + "token-hash", + "destination_exists", + ) + .await + .is_err()); + + let requests = repository.list_receiver_requests(78).await.unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].status, "failed"); + assert_eq!(requests[0].reason.as_deref(), Some("destination_exists")); +} + #[tokio::test] async fn startup_expiration_is_idempotent_for_pending_requests() { let temp = tempfile::tempdir().unwrap(); @@ -582,7 +645,7 @@ async fn migrates_schema_v2_identity_without_losing_transfer() { pool.close().await; let repository = Repository::open(temp.path()).await.unwrap(); - assert_eq!(repository.schema_version().await.unwrap(), 6); + assert_eq!(repository.schema_version().await.unwrap(), 7); let stored = repository.list_transfers().await.unwrap().remove(0); assert_eq!(stored.transfer_id, 7); assert_eq!(stored.local_id, "legacy-7-send"); diff --git a/crates/vnidrop/src/tests/runtime.rs b/crates/vnidrop/src/tests/runtime.rs index 9c3bee8..895a448 100644 --- a/crates/vnidrop/src/tests/runtime.rs +++ b/crates/vnidrop/src/tests/runtime.rs @@ -164,6 +164,7 @@ fn startup_processes_persisted_delivery_receipts() { request_id: "request-94", sender_transfer_id: 49, token: "receipt-token", + failure_reason: None, }) .await .unwrap(); diff --git a/crates/vnidrop/src/transfer_state.rs b/crates/vnidrop/src/transfer_state.rs index 7952671..b13f619 100644 --- a/crates/vnidrop/src/transfer_state.rs +++ b/crates/vnidrop/src/transfer_state.rs @@ -87,6 +87,7 @@ pub(crate) enum ReceiverRequestStatus { Refused, Expired, Completed, + Failed, } impl ReceiverRequestStatus { @@ -97,6 +98,7 @@ impl ReceiverRequestStatus { Self::Refused => "refused", Self::Expired => "expired", Self::Completed => "completed", + Self::Failed => "failed", } } } @@ -111,6 +113,7 @@ impl TryFrom<&str> for ReceiverRequestStatus { "refused" => Ok(Self::Refused), "expired" => Ok(Self::Expired), "completed" => Ok(Self::Completed), + "failed" => Ok(Self::Failed), _ => bail!("unknown receiver request status: {value}"), } } diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs index 680f0d1..64d909a 100644 --- a/crates/vnidrop/tests/transfer.rs +++ b/crates/vnidrop/tests/transfer.rs @@ -24,6 +24,30 @@ fn wait_for_sender_transfer_event(sender: &TestNode, transfer_id: u64, kind: &st } } +fn wait_for_receiver_status( + sender: &TestNode, + transfer_id: u64, + status: &str, +) -> vnidrop::ReceiverRequest { + let started = Instant::now(); + loop { + if let Some(request) = sender + .core + .list_receiver_requests(transfer_id) + .unwrap() + .into_iter() + .find(|request| request.status == status) + { + return request; + } + assert!( + started.elapsed() < Duration::from_secs(5), + "timed out waiting for receiver status {status}" + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + #[test] fn transfers_file_between_two_cores() { let source_dir = tempfile::tempdir().unwrap(); @@ -162,4 +186,11 @@ fn receive_refuses_to_overwrite_existing_destination() { && event.kind == "failed" && event.data_json.contains("\"code\":\"destination_exists\"") })); + let failed = wait_for_receiver_status(&sender, share.transfer_id, "failed"); + assert_eq!(failed.reason.as_deref(), Some("destination_exists")); + assert!(sender.sink.events().iter().any(|event| { + event.transfer_id == Some(share.transfer_id) + && event.phase == "delivery" + && event.kind == "receiver-failed" + })); } diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index 88f4974..00dbdbc 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -274,6 +274,7 @@ Noch niemand hat diese Übertragung angefragt. Genehmigt – wartet auf Abschluss Erfolgreich empfangen + Übertragung fehlgeschlagen Anfrage abgelaufen Anfrage abgelehnt Wartet auf Ihre Genehmigung diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index cadea0e..24bc04c 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -274,6 +274,7 @@ Nadie ha solicitado aún esta transferencia. Aprobado: esperando a que se complete Recibido correctamente + Transferencia fallida Solicitud caducada Solicitud rechazada Esperando su aprobación diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index ec03364..6d239e5 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -274,6 +274,7 @@ Personne n’a encore demandé ce transfert. Approuvé — en attente de la fin Reçu avec succès + Transfert échoué Demande expirée Demande refusée En attente de votre approbation diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index ec630da..f6dbcb7 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -274,6 +274,7 @@ Nessuno ha ancora richiesto questo trasferimento. Approvato: in attesa del completamento Ricevuto correttamente + Trasferimento non riuscito Richiesta scaduta Richiesta rifiutata In attesa della sua approvazione diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c7c35fe..c4dddac 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -274,6 +274,7 @@ Nog niemand heeft deze overdracht aangevraagd. Goedgekeurd — wachten op voltooiing Succesvol ontvangen + Overdracht mislukt Verzoek verlopen Verzoek geweigerd Wachten op uw goedkeuring diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 0ef6773..83ccdd5 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -274,6 +274,7 @@ Nikt jeszcze nie poprosił o ten transfer. Zatwierdzono — oczekiwanie na ukończenie Odebrano pomyślnie + Przesyłanie nie powiodło się Prośba wygasła Prośba odrzucona Oczekiwanie na Twoje zatwierdzenie diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 7b7851f..8f8be6c 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -274,6 +274,7 @@ Ainda ninguém pediu esta transferência. Aprovado — a aguardar conclusão Recebido com sucesso + A transferência falhou Pedido expirado Pedido recusado A aguardar a sua aprovação diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index f05064c..411b02c 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -274,6 +274,7 @@ Никто ещё не запросил эту передачу. Одобрено — ожидание завершения Успешно получено + Передача не удалась Запрос истёк Запрос отклонён Ожидание вашего одобрения diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 573617d..7ab31fb 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -274,6 +274,7 @@ Nobody has requested this transfer yet. Approved — waiting for completion Received successfully + Transfer failed Request expired Request refused Waiting for your approval diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt index f3c7768..de11ba9 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreModels.kt @@ -117,6 +117,7 @@ enum class ReceiverDeliveryStatus { Refused, Expired, Completed, + Failed, Unknown, } diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt index a467f48..543d259 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/core/CoreRepository.kt @@ -433,6 +433,7 @@ private fun ReceiverRequest.toModel(): ReceiverRequestModel = ReceiverRequestMod "refused" -> ReceiverDeliveryStatus.Refused "expired" -> ReceiverDeliveryStatus.Expired "completed" -> ReceiverDeliveryStatus.Completed + "failed" -> ReceiverDeliveryStatus.Failed else -> ReceiverDeliveryStatus.Unknown }, reason = reason, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt index 51078df..422db5d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/send/TransferDetails.kt @@ -409,13 +409,15 @@ private fun receiverStatusText(status: ReceiverDeliveryStatus) = stringResource( ReceiverDeliveryStatus.Refused -> Res.string.transfer_receiver_refused ReceiverDeliveryStatus.Expired -> Res.string.transfer_receiver_expired ReceiverDeliveryStatus.Completed -> Res.string.transfer_receiver_completed + ReceiverDeliveryStatus.Failed -> Res.string.transfer_receiver_failed ReceiverDeliveryStatus.Unknown -> Res.string.transfer_receiver_unknown }) @Composable private fun receiverStatusColor(status: ReceiverDeliveryStatus) = when (status) { ReceiverDeliveryStatus.Completed -> LocalVniDropColors.current.brandDefault - ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired -> LocalVniDropColors.current.destructiveDefault + ReceiverDeliveryStatus.Refused, ReceiverDeliveryStatus.Expired, ReceiverDeliveryStatus.Failed -> + LocalVniDropColors.current.destructiveDefault else -> LocalVniDropColors.current.foregroundLighter }