From 82fb5495767beedf0b04c4fc1f7b87bd407bdcc8 Mon Sep 17 00:00:00 2001 From: Hammed Abass Date: Wed, 22 Jul 2026 20:43:15 +0200 Subject: [PATCH] fix(core): preserve typed transfer failures --- apple/Tests/UiFeedbackTests.swift | 12 + .../Features/Receive/ReceiveModel.swift | 4 +- apple/VniDrop/Resources/Localizable.xcstrings | 262 +++++++++++++++++- .../VniDrop/UI/Feedback/UserFacingError.swift | 27 +- crates/vnidrop/src/error.rs | 142 ++++++++-- crates/vnidrop/src/filesystem.rs | 6 +- crates/vnidrop/src/runtime/receive.rs | 90 ++++-- crates/vnidrop/src/runtime/share.rs | 38 ++- crates/vnidrop/src/tests.rs | 2 + crates/vnidrop/src/tests/error.rs | 54 ++++ crates/vnidrop/tests/output_sink.rs | 60 +++- crates/vnidrop/tests/support/mod.rs | 18 +- crates/vnidrop/tests/transfer.rs | 24 +- localization/strings.json | 78 +++++- .../app/core/FileSystemService.android.kt | 160 +++++++---- .../composeResources/values-de/strings.xml | 6 +- .../composeResources/values-es/strings.xml | 6 +- .../composeResources/values-fr/strings.xml | 6 +- .../composeResources/values-it/strings.xml | 6 +- .../composeResources/values-nl/strings.xml | 6 +- .../composeResources/values-pl/strings.xml | 6 +- .../composeResources/values-pt/strings.xml | 6 +- .../composeResources/values-ru/strings.xml | 6 +- .../composeResources/values/strings.xml | 6 +- .../app/feature/receive/ReceiveViewModel.kt | 9 +- .../app/ui/feedback/UserFacingError.kt | 22 ++ .../com/vnidrop/app/feature/ViewModelsTest.kt | 25 ++ .../app/ui/feedback/UserFacingErrorTest.kt | 33 +++ 28 files changed, 950 insertions(+), 170 deletions(-) create mode 100644 crates/vnidrop/src/tests/error.rs diff --git a/apple/Tests/UiFeedbackTests.swift b/apple/Tests/UiFeedbackTests.swift index c22bc32..7c1df1e 100644 --- a/apple/Tests/UiFeedbackTests.swift +++ b/apple/Tests/UiFeedbackTests.swift @@ -1,4 +1,5 @@ import XCTest +import VnidropCore @testable import VniDrop /// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`. @@ -50,4 +51,15 @@ final class UserFacingErrorTests: XCTestCase { func testToUiTextFallsBackToGeneric() { XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) } + + func testToUiTextMapsTypedTransferFailures() { + XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource("error_filesystem")) + XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource("error_destination_exists")) + XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource("error_storage_full")) + XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource("error_network")) + XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource("error_invalid_input")) + XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput) + XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput) + XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput) + } } diff --git a/apple/VniDrop/Features/Receive/ReceiveModel.swift b/apple/VniDrop/Features/Receive/ReceiveModel.swift index 8957007..919f287 100644 --- a/apple/VniDrop/Features/Receive/ReceiveModel.swift +++ b/apple/VniDrop/Features/Receive/ReceiveModel.swift @@ -192,8 +192,8 @@ final class ReceiveModel: ObservableObject { messages.tryShow(UiMessage( text: uiText, tone: .error, - actionLabel: .resource("button_retry"), - onAction: { self.receive() } + actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil, + onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil )) } } diff --git a/apple/VniDrop/Resources/Localizable.xcstrings b/apple/VniDrop/Resources/Localizable.xcstrings index 50f84eb..c40866d 100644 --- a/apple/VniDrop/Resources/Localizable.xcstrings +++ b/apple/VniDrop/Resources/Localizable.xcstrings @@ -5109,6 +5109,66 @@ } } }, + "error_destination_exists": { + "comment": "Error: a received file would overwrite an existing destination file.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "A file with the same name already exists in the destination. Choose another folder or remove the existing file." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл." + } + } + } + }, "error_device_info": { "comment": "Error: device information could not be loaded.", "extractionState": "manual", @@ -5349,6 +5409,66 @@ } } }, + "error_invalid_input": { + "comment": "Error: transfer input or metadata is invalid.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Some transfer information is invalid. Review your selection or ask the sender to share again." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à l’expéditeur de partager à nouveau." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова." + } + } + } + }, "error_invalid_ticket": { "comment": "Error: the invitation/ticket could not be parsed.", "extractionState": "manual", @@ -5529,6 +5649,66 @@ } } }, + "error_network": { + "comment": "Error: the sender could not be reached over the local network.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "VniDrop could not reach the sender. Check the connection on both devices and try again." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку." + } + } + } + }, "error_nfc": { "comment": "Error: the NFC tag could not be read/used.", "extractionState": "manual", @@ -5949,62 +6129,122 @@ } } }, - "error_transfer": { - "comment": "Error: the transfer could not be completed.", + "error_storage_full": { + "comment": "Error: the destination does not have enough free storage.", "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "needs_review", - "value": "Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut." + "value": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut." } }, "en": { "stringUnit": { "state": "translated", - "value": "The transfer could not be completed. Check your connection and try again." + "value": "There is not enough storage space to save this transfer. Free up space and try again." } }, "es": { "stringUnit": { "state": "needs_review", - "value": "No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo." + "value": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo." } }, "fr": { "stringUnit": { "state": "needs_review", - "value": "Le transfert n’a pas pu être terminé. Vérifiez votre connexion et réessayez." + "value": "L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez." } }, "it": { "stringUnit": { "state": "needs_review", - "value": "Impossibile completare il trasferimento. Controlli la connessione e riprovi." + "value": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi." } }, "nl": { "stringUnit": { "state": "needs_review", - "value": "De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw." + "value": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw." } }, "pl": { "stringUnit": { "state": "needs_review", - "value": "Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie." + "value": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie." } }, "pt": { "stringUnit": { "state": "needs_review", - "value": "Não foi possível concluir a transferência. Verifique a sua ligação e tente novamente." + "value": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente." } }, "ru": { "stringUnit": { "state": "needs_review", - "value": "Не удалось завершить передачу. Проверьте подключение и повторите попытку." + "value": "Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку." + } + } + } + }, + "error_transfer": { + "comment": "Error: transfer data could not be processed; network failures use error_network.", + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The transfer data could not be processed. Ask the sender to share it again." + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos." + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau." + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo." + } + }, + "nl": { + "stringUnit": { + "state": "needs_review", + "value": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen." + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie." + } + }, + "pt": { + "stringUnit": { + "state": "needs_review", + "value": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente." + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова." } } } diff --git a/apple/VniDrop/UI/Feedback/UserFacingError.swift b/apple/VniDrop/UI/Feedback/UserFacingError.swift index 2859884..bac00c4 100644 --- a/apple/VniDrop/UI/Feedback/UserFacingError.swift +++ b/apple/VniDrop/UI/Feedback/UserFacingError.swift @@ -13,10 +13,22 @@ extension Error { return .resource("error_permission") case .Filesystem: return .resource("error_filesystem") + case .FilesystemPermission: + return .resource("error_filesystem") + case .DestinationExists: + return .resource("error_destination_exists") + case .StorageFull: + return .resource("error_storage_full") + case .Network: + return .resource("error_network") case .Transfer(let reason): return transferUiText(reason) case .Repository: return .resource("error_repository") + case .Cancelled: + return .resource("error_generic") + case .InvalidInput: + return .resource("error_invalid_input") case .Initialization(let reason): return initializationUiText(reason) case .Internal(let reason): @@ -28,6 +40,7 @@ extension Error { /// True when the user intentionally backed out of a flow. var isUserCancellation: Bool { + if let vni = self as? VnidropError, case .Cancelled = vni { return true } let haystack = technicalDetail.lowercased() if haystack.isEmpty { // URLError / CocoaError cancellation without a message. @@ -44,13 +57,23 @@ extension Error { var technicalDetail: String { if let vni = self as? VnidropError { switch vni { - case .Initialization(let r), .Ticket(let r), .Filesystem(let r), - .Transfer(let r), .Permission(let r), .Repository(let r), .Internal(let r): + case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r), + .DestinationExists(let r), .StorageFull(let r), .Network(let r), + .Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r), + .InvalidInput(let r), .Internal(let r): return r } } return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription } + + var canRetryWithoutChangingInput: Bool { + guard let vni = self as? VnidropError else { return true } + switch vni { + case .FilesystemPermission, .DestinationExists, .InvalidInput: return false + default: return true + } + } } private func transferUiText(_ reason: String) -> UiText { diff --git a/crates/vnidrop/src/error.rs b/crates/vnidrop/src/error.rs index a431309..2ade7b9 100644 --- a/crates/vnidrop/src/error.rs +++ b/crates/vnidrop/src/error.rs @@ -8,12 +8,24 @@ pub enum VnidropError { Ticket { reason: String }, #[error("filesystem error: {reason}")] Filesystem { reason: String }, + #[error("filesystem permission denied: {reason}")] + FilesystemPermission { reason: String }, + #[error("destination already exists: {reason}")] + DestinationExists { reason: String }, + #[error("storage is full: {reason}")] + StorageFull { reason: String }, + #[error("network error: {reason}")] + Network { reason: String }, #[error("transfer error: {reason}")] Transfer { reason: String }, #[error("permission error: {reason}")] Permission { reason: String }, #[error("repository error: {reason}")] Repository { reason: String }, + #[error("operation cancelled: {reason}")] + Cancelled { reason: String }, + #[error("invalid input: {reason}")] + InvalidInput { reason: String }, #[error("internal error: {reason}")] Internal { reason: String }, } @@ -32,42 +44,138 @@ impl VnidropError { } pub(crate) fn filesystem(error: impl Into) -> Self { - Self::Filesystem { - reason: error.into().to_string(), - } + let error = error.into(); + Self::classify(error, |reason| Self::Filesystem { reason }) + } + + pub(crate) fn network(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::Network { reason }) } pub(crate) fn transfer(error: impl Into) -> Self { - Self::Transfer { - reason: error.into().to_string(), - } + let error = error.into(); + Self::classify(error, |reason| Self::Transfer { reason }) } pub(crate) fn permission(error: impl Into) -> Self { - Self::Permission { - reason: error.into().to_string(), - } + Self::classify(error.into(), |reason| Self::Permission { reason }) } pub(crate) fn repository(error: impl Into) -> Self { - Self::Repository { - reason: error.into().to_string(), + Self::from_error(error.into(), |reason| Self::Repository { reason }) + } + + pub(crate) fn cancelled(reason: impl Into) -> Self { + Self::Cancelled { + reason: reason.into(), + } + } + + pub(crate) fn invalid_input(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::InvalidInput { reason }) + } + + pub(crate) fn internal(error: impl Into) -> Self { + Self::from_error(error.into(), |reason| Self::Internal { reason }) + } + + pub(crate) fn code(&self) -> &'static str { + match self { + Self::Initialization { .. } => "initialization", + Self::Ticket { .. } => "invalid_ticket", + Self::Filesystem { .. } => "filesystem", + Self::FilesystemPermission { .. } => "filesystem_permission_denied", + Self::DestinationExists { .. } => "destination_exists", + Self::StorageFull { .. } => "storage_full", + Self::Network { .. } => "network", + Self::Transfer { .. } => "transfer", + Self::Permission { .. } => "permission_denied", + Self::Repository { .. } => "repository", + Self::Cancelled { .. } => "cancelled", + Self::InvalidInput { .. } => "invalid_input", + Self::Internal { .. } => "internal", + } + } + + pub(crate) fn reason(&self) -> &str { + match self { + Self::Initialization { reason } + | Self::Ticket { reason } + | Self::Filesystem { reason } + | Self::FilesystemPermission { reason } + | Self::DestinationExists { reason } + | Self::StorageFull { reason } + | Self::Network { reason } + | Self::Transfer { reason } + | Self::Permission { reason } + | Self::Repository { reason } + | Self::Cancelled { reason } + | Self::InvalidInput { reason } + | Self::Internal { reason } => reason, + } + } + + fn classify(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self { + let reason = error.to_string(); + if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::()) { + return existing.with_reason(reason); + } + if let Some(io_error) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return match io_error.kind() { + io::ErrorKind::AlreadyExists => Self::DestinationExists { reason }, + io::ErrorKind::PermissionDenied => Self::FilesystemPermission { reason }, + io::ErrorKind::StorageFull => Self::StorageFull { reason }, + _ => Self::Filesystem { reason }, + }; + } + if error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + { + return Self::Repository { reason }; + } + fallback(reason) + } + + fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self { + let reason = error.to_string(); + if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::()) { + existing.with_reason(reason) + } else { + fallback(reason) + } + } + + fn with_reason(&self, reason: String) -> Self { + match self { + Self::Initialization { .. } => Self::Initialization { reason }, + Self::Ticket { .. } => Self::Ticket { reason }, + Self::Filesystem { .. } => Self::Filesystem { reason }, + Self::FilesystemPermission { .. } => Self::FilesystemPermission { reason }, + Self::DestinationExists { .. } => Self::DestinationExists { reason }, + Self::StorageFull { .. } => Self::StorageFull { reason }, + Self::Network { .. } => Self::Network { reason }, + Self::Transfer { .. } => Self::Transfer { reason }, + Self::Permission { .. } => Self::Permission { reason }, + Self::Repository { .. } => Self::Repository { reason }, + Self::Cancelled { .. } => Self::Cancelled { reason }, + Self::InvalidInput { .. } => Self::InvalidInput { reason }, + Self::Internal { .. } => Self::Internal { reason }, } } } impl From for VnidropError { fn from(error: anyhow::Error) -> Self { - Self::Internal { - reason: error.to_string(), - } + Self::classify(error, |reason| Self::Internal { reason }) } } impl From for VnidropError { fn from(error: io::Error) -> Self { - Self::Filesystem { - reason: error.to_string(), - } + Self::filesystem(error) } } diff --git a/crates/vnidrop/src/filesystem.rs b/crates/vnidrop/src/filesystem.rs index 6524c78..77cace6 100644 --- a/crates/vnidrop/src/filesystem.rs +++ b/crates/vnidrop/src/filesystem.rs @@ -69,7 +69,11 @@ impl AtomicOutputFile { } cleanup_stale_temporary_files(parent, STALE_PART_AGE)?; if std::fs::symlink_metadata(&target).is_ok() { - anyhow::bail!("destination already exists: {}", target.display()); + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("destination already exists: {}", target.display()), + ) + .into()); } let final_name = target diff --git a/crates/vnidrop/src/runtime/receive.rs b/crates/vnidrop/src/runtime/receive.rs index 8121bce..6b7ddb9 100644 --- a/crates/vnidrop/src/runtime/receive.rs +++ b/crates/vnidrop/src/runtime/receive.rs @@ -21,6 +21,7 @@ use crate::{ ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode, TransferMetadata, }, + error::VnidropError, filesystem::{ validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, AtomicOutputFile, @@ -52,7 +53,7 @@ pub(super) struct OutputSinkFile<'a> { impl<'a> OutputSinkFile<'a> { fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result { sink.start_file(relative_path.clone()) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; + .map_err(anyhow::Error::new)?; Ok(Self { sink, relative_path, @@ -63,7 +64,7 @@ impl<'a> OutputSinkFile<'a> { fn write(&self, bytes: Vec) -> Result<()> { self.sink .write_chunk(self.relative_path.clone(), bytes) - .map_err(|error| anyhow::anyhow!(error.to_string())) + .map_err(anyhow::Error::new) } fn finish(mut self) -> Result<()> { @@ -73,7 +74,7 @@ impl<'a> OutputSinkFile<'a> { self.terminal = true; self.sink .finish_file(self.relative_path.clone()) - .map_err(|error| anyhow::anyhow!(error.to_string())) + .map_err(anyhow::Error::new) } } @@ -104,7 +105,7 @@ pub(super) struct OutputSinkFileV2<'a> { impl<'a> OutputSinkFileV2<'a> { fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result { sink.start_file(relative_path.clone()) - .map_err(|error| anyhow::anyhow!(error.to_string()))?; + .map_err(anyhow::Error::new)?; Ok(Self { sink, relative_path, @@ -115,14 +116,14 @@ impl<'a> OutputSinkFileV2<'a> { fn write(&self, bytes: Vec) -> Result<()> { self.sink .write_chunk(self.relative_path.clone(), bytes) - .map_err(|error| anyhow::anyhow!(error.to_string())) + .map_err(anyhow::Error::new) } fn finish(mut self) -> Result { self.terminal = true; self.sink .finish_file(self.relative_path.clone()) - .map_err(|error| anyhow::anyhow!(error.to_string())) + .map_err(anyhow::Error::new) } } @@ -189,7 +190,8 @@ impl CoreInner { .transfer_slots .acquire() .await - .context("transfer limiter is closed")?; + .context("transfer limiter is closed") + .map_err(VnidropError::internal)?; let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits) .context("failed to parse transfer ticket") { @@ -205,7 +207,8 @@ impl CoreInner { }; let transfer_id = parsed.metadata.transfer_id; self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) - .await?; + .await + .map_err(VnidropError::repository)?; // 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(); @@ -221,8 +224,10 @@ impl CoreInner { ); let (result, cancelled) = tokio::select! { - result = self.receive_inner(transfer_id, parsed, target, receiver_name) => (result, false), - _ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true), + result = self.receive_inner(transfer_id, parsed, target, receiver_name) => { + (result.map_err(VnidropError::transfer), false) + }, + _ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true), }; self.active_transfers @@ -238,7 +243,7 @@ impl CoreInner { "receive", "error", "failed", - json!({ "reason": error.to_string() }), + json!({ "code": error.code(), "reason": error.reason() }), ); let _ = self .repository @@ -250,7 +255,7 @@ impl CoreInner { .await; } } - result + result.map_err(anyhow::Error::new) } pub(super) async fn receive_inner( @@ -261,7 +266,9 @@ impl CoreInner { receiver_name: Option, ) -> Result<()> { if let ReceiveTarget::Directory(output_dir) = &target { - tokio::fs::create_dir_all(output_dir).await?; + tokio::fs::create_dir_all(output_dir) + .await + .map_err(VnidropError::filesystem)?; } let sender_addr = parsed.blob_ticket.addr().clone(); @@ -278,14 +285,16 @@ impl CoreInner { let connection = self .endpoint .connect(sender_addr.clone(), iroh_blobs::ALPN) - .await?; + .await + .map_err(VnidropError::network)?; self.emit_transfer(transfer_id, "receive", "network", "connected", json!({})); let hash_and_format = parsed.blob_ticket.hash_and_format(); let (_hash_seq, sizes) = get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) .await - .context("failed to get file sizes")?; + .context("failed to get file sizes") + .map_err(VnidropError::network)?; let total_size = sizes .iter() .try_fold(0u64, |total, size| total.checked_add(*size)) @@ -331,7 +340,11 @@ impl CoreInner { ); } GetProgressItem::Done(_) => break, - GetProgressItem::Error(error) => anyhow::bail!("download failed: {error}"), + GetProgressItem::Error(error) => { + return Err( + VnidropError::network(anyhow::anyhow!("download failed: {error}")).into(), + ); + } } } @@ -344,7 +357,8 @@ impl CoreInner { TransferStatus::Receiving, TransferStatus::Done, ) - .await?; + .await + .map_err(VnidropError::repository)?; drop(download_tag); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); let sender_transfer_id = delivery_receipt.transfer_id; @@ -395,7 +409,8 @@ impl CoreInner { total_size: parsed.metadata.total_size, access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), }) - .await?; + .await + .map_err(VnidropError::repository)?; let metadata_json = serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null); self.emit_transfer( @@ -433,8 +448,9 @@ impl CoreInner { match client .request_transfer(metadata, receiver_name) .await - .map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))? - { + .map_err(|error| { + VnidropError::network(anyhow::anyhow!("handshake request failed: {error}")) + })? { HandshakeResponse::Approved { request_id, token, @@ -456,9 +472,10 @@ impl CoreInner { token, }) } - HandshakeResponse::Denied { reason } => { - anyhow::bail!("transfer request was denied by sender: {reason}") - } + HandshakeResponse::Denied { reason } => Err(VnidropError::permission(anyhow::anyhow!( + "transfer request was denied by sender: {reason}" + )) + .into()), } } @@ -469,7 +486,11 @@ impl CoreInner { target: ReceiveTarget, collection: Collection, ) -> Result<()> { - let transfer_local_id = self.repository.transfer_local_id(transfer_id).await?; + let transfer_local_id = self + .repository + .transfer_local_id(transfer_id) + .await + .map_err(VnidropError::repository)?; let received_transfer = ReceivedTransfer { protocol_id: transfer_id, local_id: &transfer_local_id, @@ -529,7 +550,8 @@ impl CoreInner { self.limits.max_path_bytes ); } - let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path)?; + let (pending_file, writer) = AtomicOutputFile::create(output_dir, relative_path) + .map_err(VnidropError::filesystem)?; let (tx, rx) = async_channel::bounded::>>(2); let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx)); let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream(); @@ -572,10 +594,16 @@ impl CoreInner { tx.send(Ok(None)) .await .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; - let writer = wait_for_writer(writer_task).await??; - tokio::task::spawn_blocking(move || writer.sync_all()).await??; + let writer = wait_for_writer(writer_task) + .await + .map_err(VnidropError::internal)? + .map_err(VnidropError::filesystem)?; + tokio::task::spawn_blocking(move || writer.sync_all()) + .await + .map_err(VnidropError::internal)? + .map_err(VnidropError::filesystem)?; let locator = pending_file.target().to_string_lossy().to_string(); - pending_file.commit()?; + pending_file.commit().map_err(VnidropError::filesystem)?; self.repository .record_received_artifact(ReceivedArtifactInsert { transfer_local_id: transfer.local_id, @@ -585,7 +613,8 @@ impl CoreInner { locator: &locator, logical_size: exported, }) - .await?; + .await + .map_err(VnidropError::repository)?; Ok(()) } @@ -712,7 +741,8 @@ impl CoreInner { locator: &published.locator, logical_size: exported, }) - .await?; + .await + .map_err(VnidropError::repository)?; Ok(()) } } diff --git a/crates/vnidrop/src/runtime/share.rs b/crates/vnidrop/src/runtime/share.rs index 56e4511..1b2c211 100644 --- a/crates/vnidrop/src/runtime/share.rs +++ b/crates/vnidrop/src/runtime/share.rs @@ -17,6 +17,7 @@ use crate::{ access_policy::mode_to_storage, api::TransferMetadata, api::{ShareMetadataInput, ShareResult, ShareSource}, + error::VnidropError, filesystem::{ collect_import_files_with_limits, default_collection_name, read_stream_from_blocking_reader, TransferImport, @@ -37,22 +38,29 @@ impl CoreInner { .transfer_slots .acquire() .await - .context("transfer limiter is closed")?; + .context("transfer limiter is closed") + .map_err(VnidropError::internal)?; let transfer_id = metadata.transfer_id; if sources.is_empty() { - anyhow::bail!("at least one source is required"); + return Err(VnidropError::invalid_input(anyhow::anyhow!( + "at least one source is required" + )) + .into()); } if sources.len() as u64 > self.limits.max_sources { - anyhow::bail!( + return Err(VnidropError::invalid_input(anyhow::anyhow!( "source count {} exceeds limit {}", sources.len(), self.limits.max_sources - ); + )) + .into()); } self.limits - .validate_metadata_text("transfer name", metadata.transfer_name.as_deref())?; + .validate_metadata_text("transfer name", metadata.transfer_name.as_deref()) + .map_err(VnidropError::invalid_input)?; self.limits - .validate_metadata_text("sender name", metadata.sender_name.as_deref())?; + .validate_metadata_text("sender name", metadata.sender_name.as_deref()) + .map_err(VnidropError::invalid_input)?; self.repository .insert_transfer(TransferUpsert { transfer_id, @@ -66,7 +74,8 @@ impl CoreInner { total_size: 0, access_mode: mode_to_storage(&metadata.access_mode), }) - .await?; + .await + .map_err(VnidropError::repository)?; let (cancel, mut cancelled) = oneshot::channel(); self.active_transfers .lock() @@ -79,8 +88,10 @@ impl CoreInner { }, ); let (result, was_cancelled) = tokio::select! { - result = self.share_files_inner(sources, metadata) => (result, false), - _ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true), + result = self.share_files_inner(sources, metadata) => { + (result.map_err(VnidropError::transfer), false) + }, + _ = &mut cancelled => (Err(VnidropError::cancelled("transfer cancelled")), true), }; self.active_transfers .lock() @@ -95,7 +106,7 @@ impl CoreInner { "send", "error", "failed", - json!({ "reason": error.to_string() }), + json!({ "code": error.code(), "reason": error.reason() }), ); let _ = self .repository @@ -107,7 +118,7 @@ impl CoreInner { .await; } } - result + result.map_err(anyhow::Error::new) } pub(super) async fn share_files_inner( @@ -145,7 +156,8 @@ impl CoreInner { let local_id = self .repository .transfer_local_id(metadata.transfer_id) - .await?; + .await + .map_err(VnidropError::repository)?; let tag_name = share_tag_name(&local_id); self.store .tags() @@ -175,7 +187,7 @@ impl CoreInner { .await { let _ = self.store.tags().delete(&tag_name).await; - return Err(error); + return Err(VnidropError::repository(error).into()); } // Map root + every collection member so provider ACL cannot fail-open // on child blob hashes that are not the collection root. diff --git a/crates/vnidrop/src/tests.rs b/crates/vnidrop/src/tests.rs index ba9f72a..859a73d 100644 --- a/crates/vnidrop/src/tests.rs +++ b/crates/vnidrop/src/tests.rs @@ -1,5 +1,7 @@ #[path = "tests/access_policy.rs"] mod access_policy_tests; +#[path = "tests/error.rs"] +mod error_tests; #[path = "tests/filesystem.rs"] mod filesystem_tests; #[path = "tests/handshake.rs"] diff --git a/crates/vnidrop/src/tests/error.rs b/crates/vnidrop/src/tests/error.rs new file mode 100644 index 0000000..9545c49 --- /dev/null +++ b/crates/vnidrop/src/tests/error.rs @@ -0,0 +1,54 @@ +use std::io; + +use crate::VnidropError; + +#[test] +fn transfer_boundary_classifies_operating_system_failures() { + let already_exists = VnidropError::transfer(io::Error::new( + io::ErrorKind::AlreadyExists, + "target exists", + )); + let permission = VnidropError::transfer(io::Error::new( + io::ErrorKind::PermissionDenied, + "access denied", + )); + let storage = VnidropError::transfer(io::Error::new(io::ErrorKind::StorageFull, "disk full")); + let filesystem = + VnidropError::transfer(io::Error::new(io::ErrorKind::NotFound, "folder missing")); + + assert!(matches!( + already_exists, + VnidropError::DestinationExists { .. } + )); + assert!(matches!( + permission, + VnidropError::FilesystemPermission { .. } + )); + assert!(matches!(storage, VnidropError::StorageFull { .. })); + assert!(matches!(filesystem, VnidropError::Filesystem { .. })); +} + +#[test] +fn transfer_boundary_preserves_typed_errors_through_context() { + let error = anyhow::Error::new(VnidropError::Network { + reason: "connection reset".to_string(), + }) + .context("download failed"); + + let classified = VnidropError::transfer(error); + + assert!(matches!( + classified, + VnidropError::Network { ref reason } if reason == "download failed" + )); + assert_eq!(classified.code(), "network"); +} + +#[test] +fn transfer_boundary_classifies_database_failures() { + let transfer = VnidropError::transfer(sqlx::Error::RowNotFound); + let approval = VnidropError::permission(sqlx::Error::RowNotFound); + + assert!(matches!(transfer, VnidropError::Repository { .. })); + assert!(matches!(approval, VnidropError::Repository { .. })); +} diff --git a/crates/vnidrop/tests/output_sink.rs b/crates/vnidrop/tests/output_sink.rs index 6f0c2ed..76e7bfb 100644 --- a/crates/vnidrop/tests/output_sink.rs +++ b/crates/vnidrop/tests/output_sink.rs @@ -8,6 +8,29 @@ use support::{ receive_with_sink_response, receive_with_sink_v2_response, share_path, wait_for_receiver_request, MemoryOutputSink, TestNode, }; +use vnidrop::{PublishedOutput, ReceiveOutputSinkV2, VnidropError}; + +struct ExistingDestinationSink; + +impl ReceiveOutputSinkV2 for ExistingDestinationSink { + fn start_file(&self, relative_path: String) -> Result<(), VnidropError> { + Err(VnidropError::DestinationExists { + reason: format!("destination already exists: {relative_path}"), + }) + } + + fn write_chunk(&self, _relative_path: String, _bytes: Vec) -> Result<(), VnidropError> { + unreachable!("a rejected file must not be written") + } + + fn finish_file(&self, _relative_path: String) -> Result { + unreachable!("a rejected file must not be finished") + } + + fn abort_file(&self, _relative_path: String, _reason: String) -> Result<(), VnidropError> { + unreachable!("start_file failure must not be aborted") + } +} #[test] fn versioned_sink_records_published_locator() { @@ -89,10 +112,45 @@ fn reports_output_sink_write_failure() { ) .unwrap_err(); - assert!(error.contains("sink write failed")); + assert!(matches!( + error, + VnidropError::Filesystem { ref reason } if reason.contains("sink write failed") + )); assert_eq!(output_sink.terminal_state("hello.txt"), Some("aborted")); } +#[test] +fn preserves_typed_output_sink_failure() { + let source_dir = tempfile::tempdir().unwrap(); + let source_path = source_dir.path().join("existing.txt"); + std::fs::write(&source_path, b"new content").unwrap(); + let sender = TestNode::new(); + let receiver = TestNode::new(); + let share = share_path(&sender.core, &source_path, 39, "existing.txt", false); + + let error = receive_with_sink_v2_response( + &sender.core, + share.transfer_id, + receiver.core.arc(), + share.ticket, + Arc::new(ExistingDestinationSink), + true, + ) + .unwrap_err(); + + assert!(matches!(error, VnidropError::DestinationExists { .. })); + assert!(receiver + .core + .list_events(Some(39)) + .unwrap() + .iter() + .any(|event| { + event.phase == "error" + && event.kind == "failed" + && event.data_json.contains("\"code\":\"destination_exists\"") + })); +} + #[test] fn cancellation_during_export_aborts_open_sink_file() { // Gate the first write so export is mid-file when cancel runs. A large diff --git a/crates/vnidrop/tests/support/mod.rs b/crates/vnidrop/tests/support/mod.rs index f628697..d5cc1e2 100644 --- a/crates/vnidrop/tests/support/mod.rs +++ b/crates/vnidrop/tests/support/mod.rs @@ -292,12 +292,10 @@ pub fn receive_with_response( ticket: String, output_dir: &Path, accepted: bool, -) -> Result<(), String> { +) -> Result<(), VnidropError> { let output_dir = output_dir.to_string_lossy().to_string(); let handle = std::thread::spawn(move || { - receiver - .receive(ticket, output_dir, Some("receiver".to_string())) - .map_err(|error| error.to_string()) + receiver.receive(ticket, output_dir, Some("receiver".to_string())) }); respond_to_pending_request(sender, transfer_id, accepted); handle.join().unwrap() @@ -310,11 +308,9 @@ pub fn receive_with_sink_response( ticket: String, output_sink: Arc, accepted: bool, -) -> Result<(), String> { +) -> Result<(), VnidropError> { let handle = std::thread::spawn(move || { - receiver - .receive_with_output_sink(ticket, output_sink, Some("receiver".to_string())) - .map_err(|error| error.to_string()) + receiver.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string())) }); respond_to_pending_request(sender, transfer_id, accepted); handle.join().unwrap() @@ -327,11 +323,9 @@ pub fn receive_with_sink_v2_response( ticket: String, output_sink: Arc, accepted: bool, -) -> Result<(), String> { +) -> Result<(), VnidropError> { let handle = std::thread::spawn(move || { - receiver - .receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string())) - .map_err(|error| error.to_string()) + receiver.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string())) }); respond_to_pending_request(sender, transfer_id, accepted); handle.join().unwrap() diff --git a/crates/vnidrop/tests/transfer.rs b/crates/vnidrop/tests/transfer.rs index c4fd497..6544150 100644 --- a/crates/vnidrop/tests/transfer.rs +++ b/crates/vnidrop/tests/transfer.rs @@ -1,6 +1,7 @@ mod support; use support::{receive_with_response, share_path, TestNode}; +use vnidrop::VnidropError; #[test] fn transfers_file_between_two_cores() { @@ -98,7 +99,7 @@ fn receive_refuses_to_overwrite_existing_destination() { let receiver = TestNode::new(); let share = share_path(&sender.core, &source_path, 27, "existing.txt", false); - assert!(receive_with_response( + let error = receive_with_response( &sender.core, share.transfer_id, receiver.core.arc(), @@ -106,7 +107,8 @@ fn receive_refuses_to_overwrite_existing_destination() { output_dir.path(), true, ) - .is_err()); + .unwrap_err(); + assert!(matches!(error, VnidropError::DestinationExists { .. })); assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content"); assert!(std::fs::read_dir(output_dir.path()) .unwrap() @@ -115,4 +117,22 @@ fn receive_refuses_to_overwrite_existing_destination() { .file_name() .to_string_lossy() .contains(".part"))); + let transfer = receiver + .core + .list_transfers() + .unwrap() + .into_iter() + .find(|transfer| transfer.transfer_id == 27) + .unwrap(); + assert_eq!(transfer.status, "failed"); + assert!(receiver + .core + .list_events(Some(27)) + .unwrap() + .iter() + .any(|event| { + event.phase == "error" + && event.kind == "failed" + && event.data_json.contains("\"code\":\"destination_exists\"") + })); } diff --git a/localization/strings.json b/localization/strings.json index 12bef6b..0ad438b 100644 --- a/localization/strings.json +++ b/localization/strings.json @@ -1281,6 +1281,20 @@ "ru": "Не удалось загрузить сведения об устройстве." } }, + "error_destination_exists": { + "context": "Error: a received file would overwrite an existing destination file.", + "translations": { + "en": "A file with the same name already exists in the destination. Choose another folder or remove the existing file.", + "fr": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.", + "es": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.", + "it": "Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente.", + "de": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.", + "pt": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.", + "pl": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.", + "nl": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.", + "ru": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл." + } + }, "error_filesystem": { "context": "Error: the selected files/folder could not be accessed.", "translations": { @@ -1323,6 +1337,20 @@ "ru": "VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку." } }, + "error_invalid_input": { + "context": "Error: transfer input or metadata is invalid.", + "translations": { + "en": "Some transfer information is invalid. Review your selection or ask the sender to share again.", + "fr": "Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à l’expéditeur de partager à nouveau.", + "es": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla.", + "it": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo.", + "de": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen.", + "pt": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente.", + "pl": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie.", + "nl": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen.", + "ru": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова." + } + }, "error_invalid_ticket": { "context": "Error: the invitation/ticket could not be parsed.", "translations": { @@ -1365,6 +1393,20 @@ "ru": "В этой сборке отсутствует нативная библиотека VniDrop." } }, + "error_network": { + "context": "Error: the sender could not be reached over the local network.", + "translations": { + "en": "VniDrop could not reach the sender. Check the connection on both devices and try again.", + "fr": "VniDrop n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez.", + "es": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo.", + "it": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi.", + "de": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut.", + "pt": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente.", + "pl": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie.", + "nl": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw.", + "ru": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку." + } + }, "error_nfc": { "context": "Error: the NFC tag could not be read/used.", "translations": { @@ -1463,18 +1505,32 @@ "ru": "VniDrop ещё запускается. Откройте приглашение снова через мгновение." } }, - "error_transfer": { - "context": "Error: the transfer could not be completed.", + "error_storage_full": { + "context": "Error: the destination does not have enough free storage.", "translations": { - "en": "The transfer could not be completed. Check your connection and try again.", - "fr": "Le transfert n’a pas pu être terminé. Vérifiez votre connexion et réessayez.", - "es": "No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo.", - "it": "Impossibile completare il trasferimento. Controlli la connessione e riprovi.", - "de": "Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", - "pt": "Não foi possível concluir a transferência. Verifique a sua ligação e tente novamente.", - "pl": "Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie.", - "nl": "De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw.", - "ru": "Не удалось завершить передачу. Проверьте подключение и повторите попытку." + "en": "There is not enough storage space to save this transfer. Free up space and try again.", + "fr": "L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez.", + "es": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.", + "it": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.", + "de": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.", + "pt": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.", + "pl": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.", + "nl": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.", + "ru": "Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку." + } + }, + "error_transfer": { + "context": "Error: transfer data could not be processed; network failures use error_network.", + "translations": { + "en": "The transfer data could not be processed. Ask the sender to share it again.", + "fr": "Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau.", + "es": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.", + "it": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.", + "de": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.", + "pt": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.", + "pl": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.", + "nl": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.", + "ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова." } }, "field_receiver_name": { diff --git a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt index ab61c68..fb8bc5b 100644 --- a/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt +++ b/shared/src/androidMain/kotlin/com/vnidrop/app/core/FileSystemService.android.kt @@ -7,6 +7,8 @@ import android.os.Build import android.os.Environment import android.provider.DocumentsContract import android.provider.MediaStore +import android.system.ErrnoException +import android.system.OsConstants import android.webkit.MimeTypeMap import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -15,7 +17,9 @@ import androidx.core.net.toUri import uniffi.vnidrop.PublishedOutput import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceivedLocatorKind +import uniffi.vnidrop.VnidropException import java.io.File +import java.io.IOException import java.io.OutputStream import java.net.URLConnection import java.util.UUID @@ -248,6 +252,32 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List receiveSinkCall(block: () -> T): T = + try { + block() + } catch (error: VnidropException) { + throw error + } catch (error: Throwable) { + throw error.toReceiveSinkException() + } + +private fun Throwable.toReceiveSinkException(): VnidropException { + val reason = message ?: toString() + var current: Throwable? = this + while (current != null) { + if (current is ErrnoException && current.errno == OsConstants.ENOSPC) { + return VnidropException.StorageFull(reason) + } + current = current.cause + } + return when (this) { + is SecurityException -> VnidropException.FilesystemPermission(reason) + is IllegalArgumentException -> VnidropException.InvalidInput(reason) + is IOException, is IllegalStateException -> VnidropException.Filesystem(reason) + else -> VnidropException.Internal(reason) + } +} + /** * Writes into the shared system Downloads collection via MediaStore. * @@ -266,42 +296,46 @@ private class AndroidMediaStoreDownloadsSink( private val resolver = context.contentResolver override fun startFile(relativePath: String) { - check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - "MediaStore Downloads requires Android 10 or newer" - } - check(relativePath !in pending) { "Output stream is already open for $relativePath" } - val parts = requireSafeRelativePathParts(relativePath) - val finalName = parts.last() - val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) - check(!mediaStoreItemExists(finalName, relativeDir)) { - "Destination already exists: $relativePath" - } - - val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, finalName) - put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeFor(finalName)) - put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir) - put(MediaStore.MediaColumns.IS_PENDING, 1) - } - val uri = resolver.insert(downloadsCollection(), values) - ?: error("Could not create Downloads entry for $relativePath") - val stream = runCatching { resolver.openOutputStream(uri, "w") } - .getOrElse { error -> - resolver.delete(uri, null, null) - throw error - } ?: run { - resolver.delete(uri, null, null) - error("Could not open output stream for $relativePath") + receiveSinkCall { + check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "MediaStore Downloads requires Android 10 or newer" } - pending[relativePath] = PendingDocument(stream, uri) + check(relativePath !in pending) { "Output stream is already open for $relativePath" } + val parts = requireSafeRelativePathParts(relativePath) + val finalName = parts.last() + val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) + if (mediaStoreItemExists(finalName, relativeDir)) { + throw VnidropException.DestinationExists("Destination already exists: $relativePath") + } + + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, finalName) + put(MediaStore.MediaColumns.MIME_TYPE, mimeTypeFor(finalName)) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativeDir) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val uri = resolver.insert(downloadsCollection(), values) + ?: error("Could not create Downloads entry for $relativePath") + val stream = runCatching { resolver.openOutputStream(uri, "w") } + .getOrElse { error -> + resolver.delete(uri, null, null) + throw error + } ?: run { + resolver.delete(uri, null, null) + error("Could not open output stream for $relativePath") + } + pending[relativePath] = PendingDocument(stream, uri) + } } override fun writeChunk(relativePath: String, bytes: ByteArray) { - val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") - document.stream.write(bytes) + receiveSinkCall { + val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") + document.stream.write(bytes) + } } - override fun finishFile(relativePath: String): PublishedOutput { + override fun finishFile(relativePath: String): PublishedOutput = receiveSinkCall { val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") try { document.stream.flush() @@ -316,13 +350,15 @@ private class AndroidMediaStoreDownloadsSink( resolver.delete(document.uri, null, null) throw error } - return PublishedOutput(ReceivedLocatorKind.ANDROID_MEDIA_STORE, document.uri.toString()) + PublishedOutput(ReceivedLocatorKind.ANDROID_MEDIA_STORE, document.uri.toString()) } override fun abortFile(relativePath: String, reason: String) { - val document = pending.remove(relativePath) ?: return - runCatching { document.stream.close() } - resolver.delete(document.uri, null, null) + receiveSinkCall { + val document = pending.remove(relativePath) ?: return@receiveSinkCall + runCatching { document.stream.close() } + resolver.delete(document.uri, null, null) + } } private fun downloadsCollection(): Uri = @@ -390,33 +426,41 @@ private class AndroidTreeReceiveOutputSink( private val pending = mutableMapOf() override fun startFile(relativePath: String) { - check(relativePath !in pending) { "Output stream is already open for $relativePath" } - // Defense in depth: Rust also validates, but sinks must reject traversal alone. - requireSafeRelativePathParts(relativePath) - val (parent, finalName) = resolveParent(relativePath) - check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } - val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" - val temporaryUri = DocumentsContract.createDocument( - context.contentResolver, - parent, - "application/octet-stream", - temporaryName, - ) ?: error("Could not create temporary file for $relativePath") - val stream = context.contentResolver.openOutputStream(temporaryUri, "w") - ?: error("Could not open output stream for $relativePath") - pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) + receiveSinkCall { + check(relativePath !in pending) { "Output stream is already open for $relativePath" } + // Defense in depth: Rust also validates, but sinks must reject traversal alone. + requireSafeRelativePathParts(relativePath) + val (parent, finalName) = resolveParent(relativePath) + if (findChild(parent, finalName) != null) { + throw VnidropException.DestinationExists("Destination already exists: $relativePath") + } + val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" + val temporaryUri = DocumentsContract.createDocument( + context.contentResolver, + parent, + "application/octet-stream", + temporaryName, + ) ?: error("Could not create temporary file for $relativePath") + val stream = context.contentResolver.openOutputStream(temporaryUri, "w") + ?: error("Could not open output stream for $relativePath") + pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) + } } override fun writeChunk(relativePath: String, bytes: ByteArray) { - val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") - document.stream.write(bytes) + receiveSinkCall { + val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") + document.stream.write(bytes) + } } - override fun finishFile(relativePath: String): PublishedOutput { + override fun finishFile(relativePath: String): PublishedOutput = receiveSinkCall { val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath") try { document.stream.close() - check(findChild(document.parentUri, document.finalName) == null) { "Destination already exists: $relativePath" } + if (findChild(document.parentUri, document.finalName) != null) { + throw VnidropException.DestinationExists("Destination already exists: $relativePath") + } val finalUri = checkNotNull( DocumentsContract.renameDocument( context.contentResolver, @@ -424,7 +468,7 @@ private class AndroidTreeReceiveOutputSink( document.finalName, ), ) { "Could not commit received file $relativePath" } - return PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString()) + PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString()) } catch (error: Throwable) { runCatching { document.stream.close() } DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) @@ -433,9 +477,11 @@ private class AndroidTreeReceiveOutputSink( } override fun abortFile(relativePath: String, reason: String) { - val document = pending.remove(relativePath) ?: return - runCatching { document.stream.close() } - DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + receiveSinkCall { + val document = pending.remove(relativePath) ?: return@receiveSinkCall + runCatching { document.stream.close() } + DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) + } } private fun resolveParent(relativePath: String): Pair { diff --git a/shared/src/commonMain/composeResources/values-de/strings.xml b/shared/src/commonMain/composeResources/values-de/strings.xml index e4bda0a..f497235 100644 --- a/shared/src/commonMain/composeResources/values-de/strings.xml +++ b/shared/src/commonMain/composeResources/values-de/strings.xml @@ -84,12 +84,15 @@ Diagnosedaten teilen Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich. Geräteinformationen konnten nicht geladen werden. + Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei. VniDrop konnte nicht auf die ausgewählten Dateien oder den Ordner zugreifen. Überprüfen Sie die Berechtigungen und versuchen Sie es erneut. Etwas ist schiefgelaufen. Versuchen Sie es erneut. VniDrop konnte den Start nicht abschließen. Schließen Sie die App und versuchen Sie es erneut. + Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen. Diese Einladung konnte nicht gelesen werden. Bitten Sie den Absender um eine neue. Diese Einladung ist leer. Versuchen Sie, sie erneut zu öffnen. Die native VniDrop-Bibliothek fehlt in diesem Build. + VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut. Dieses NFC-Tag konnte nicht verwendet werden. Versuchen Sie ein anderes. Der Absender hat diese Übertragung nicht genehmigt oder sie wurde abgelehnt. VniDrop konnte die Übertragungsdaten auf diesem Gerät nicht speichern. @@ -97,7 +100,8 @@ Wählen Sie mindestens ein Objekt zum Teilen aus. VniDrop konnte seine Netzwerk-Sockets auf diesem Gerät nicht öffnen. VniDrop startet noch. Öffnen Sie die Einladung gleich erneut. - Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut. + Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut. + Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen. Empfängername Absendername Übertragungsname diff --git a/shared/src/commonMain/composeResources/values-es/strings.xml b/shared/src/commonMain/composeResources/values-es/strings.xml index 7a5f126..b106a93 100644 --- a/shared/src/commonMain/composeResources/values-es/strings.xml +++ b/shared/src/commonMain/composeResources/values-es/strings.xml @@ -84,12 +84,15 @@ Compartir diagnósticos Se necesita acceso a la cámara para escanear un código QR. No se pudo cargar la información del dispositivo. + Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente. VniDrop no pudo acceder a los archivos o la carpeta seleccionados. Compruebe los permisos e inténtelo de nuevo. Algo salió mal. Inténtelo de nuevo. VniDrop no pudo terminar de iniciarse. Cierre la app e inténtelo de nuevo. + Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla. No se pudo leer esta invitación. Pida al remitente una nueva. Esa invitación está vacía. Intente abrirla de nuevo. Falta la biblioteca nativa de VniDrop en esta versión. + VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo. No se pudo usar esta etiqueta NFC. Pruebe con otra. El remitente no ha aprobado esta transferencia, o fue rechazada. VniDrop no pudo guardar los datos de la transferencia en este dispositivo. @@ -97,7 +100,8 @@ Seleccione al menos un elemento para compartir. VniDrop no pudo abrir sus sockets de red en este dispositivo. VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento. - No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo. + No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo. + No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos. Nombre del destinatario Nombre del remitente Nombre de la transferencia diff --git a/shared/src/commonMain/composeResources/values-fr/strings.xml b/shared/src/commonMain/composeResources/values-fr/strings.xml index 7adc010..d708fda 100644 --- a/shared/src/commonMain/composeResources/values-fr/strings.xml +++ b/shared/src/commonMain/composeResources/values-fr/strings.xml @@ -84,12 +84,15 @@ Partager les diagnostics L’accès à la caméra est nécessaire pour scanner un QR code. Impossible de charger les informations de l’appareil. + Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant. VniDrop n’a pas pu accéder aux fichiers ou au dossier sélectionnés. Vérifiez les autorisations et réessayez. Une erreur est survenue. Réessayez. VniDrop n’a pas pu terminer son démarrage. Fermez l’app et réessayez. + Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à l’expéditeur de partager à nouveau. Cette invitation n’a pas pu être lue. Demandez-en une nouvelle à l’expéditeur. Cette invitation est vide. Essayez de l’ouvrir à nouveau. La bibliothèque native de VniDrop est absente de cette version. + VniDrop n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez. Ce tag NFC n’a pas pu être utilisé. Essayez-en un autre. L’expéditeur n’a pas approuvé ce transfert, ou il a été refusé. VniDrop n’a pas pu enregistrer les données de transfert sur cet appareil. @@ -97,7 +100,8 @@ Sélectionnez au moins un élément à partager. VniDrop n’a pas pu ouvrir ses sockets réseau sur cet appareil. VniDrop démarre encore. Rouvrez l’invitation dans un instant. - Le transfert n’a pas pu être terminé. Vérifiez votre connexion et réessayez. + L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez. + Les données du transfert n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau. Nom du destinataire Nom de l’expéditeur Nom du transfert diff --git a/shared/src/commonMain/composeResources/values-it/strings.xml b/shared/src/commonMain/composeResources/values-it/strings.xml index 8ef52ac..c1540af 100644 --- a/shared/src/commonMain/composeResources/values-it/strings.xml +++ b/shared/src/commonMain/composeResources/values-it/strings.xml @@ -84,12 +84,15 @@ Condividi dati diagnostici Per scansionare un codice QR è necessario l’accesso alla fotocamera. Impossibile caricare le informazioni sul dispositivo. + Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente. VniDrop non ha potuto accedere ai file o alla cartella selezionati. Controlli le autorizzazioni e riprovi. Qualcosa è andato storto. Riprovi. VniDrop non ha potuto completare l’avvio. Chiuda l’app e riprovi. + Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo. Impossibile leggere questo invito. Ne chieda uno nuovo al mittente. Questo invito è vuoto. Provi ad aprirlo di nuovo. La libreria nativa di VniDrop non è presente in questa build. + VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi. Impossibile usare questo tag NFC. Ne provi un altro. Il mittente non ha approvato questo trasferimento, oppure è stato rifiutato. VniDrop non ha potuto salvare i dati del trasferimento su questo dispositivo. @@ -97,7 +100,8 @@ Selezioni almeno un elemento da condividere. VniDrop non ha potuto aprire i suoi socket di rete su questo dispositivo. VniDrop è ancora in fase di avvio. Riapra l’invito tra un momento. - Impossibile completare il trasferimento. Controlli la connessione e riprovi. + Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi. + Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo. Nome del destinatario Nome del mittente Nome del trasferimento diff --git a/shared/src/commonMain/composeResources/values-nl/strings.xml b/shared/src/commonMain/composeResources/values-nl/strings.xml index c8c5e12..f3f4f17 100644 --- a/shared/src/commonMain/composeResources/values-nl/strings.xml +++ b/shared/src/commonMain/composeResources/values-nl/strings.xml @@ -84,12 +84,15 @@ Diagnostische gegevens delen Voor het scannen van een QR-code is toegang tot de camera vereist. Apparaatgegevens konden niet worden geladen. + Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand. VniDrop kon geen toegang krijgen tot de geselecteerde bestanden of map. Controleer de machtigingen en probeer het opnieuw. Er is iets misgegaan. Probeer het opnieuw. VniDrop kon het opstarten niet voltooien. Sluit de app en probeer het opnieuw. + Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen. Deze uitnodiging kon niet worden gelezen. Vraag de afzender om een nieuwe. Die uitnodiging is leeg. Probeer deze opnieuw te openen. De native VniDrop-bibliotheek ontbreekt in deze build. + VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw. Deze NFC-tag kon niet worden gebruikt. Probeer een andere. De afzender heeft deze overdracht niet goedgekeurd, of deze is geweigerd. VniDrop kon de overdrachtsgegevens niet op dit apparaat bewaren. @@ -97,7 +100,8 @@ Selecteer minstens één item om te delen. VniDrop kon zijn netwerksockets niet openen op dit apparaat. VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw. - De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw. + Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw. + De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen. Naam van ontvanger Naam van afzender Naam van overdracht diff --git a/shared/src/commonMain/composeResources/values-pl/strings.xml b/shared/src/commonMain/composeResources/values-pl/strings.xml index 7e3c6b5..6d7c74f 100644 --- a/shared/src/commonMain/composeResources/values-pl/strings.xml +++ b/shared/src/commonMain/composeResources/values-pl/strings.xml @@ -84,12 +84,15 @@ Udostępniaj diagnostykę Do zeskanowania kodu QR wymagany jest dostęp do aparatu. Nie udało się wczytać informacji o urządzeniu. + W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik. VniDrop nie mógł uzyskać dostępu do wybranych plików lub folderu. Sprawdź uprawnienia i spróbuj ponownie. Coś poszło nie tak. Spróbuj ponownie. VniDrop nie mógł dokończyć uruchamiania. Zamknij aplikację i spróbuj ponownie. + Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie. Nie udało się odczytać tego zaproszenia. Poproś nadawcę o nowe. To zaproszenie jest puste. Spróbuj otworzyć je ponownie. W tej kompilacji brakuje natywnej biblioteki VniDrop. + VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie. Nie udało się użyć tego tagu NFC. Spróbuj innego. Nadawca nie zatwierdził tego transferu lub został on odrzucony. VniDrop nie mógł zapisać danych transferu na tym urządzeniu. @@ -97,7 +100,8 @@ Wybierz co najmniej jeden element do udostępnienia. VniDrop nie mógł otworzyć gniazd sieciowych na tym urządzeniu. VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę. - Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie. + Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie. + Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie. Nazwa odbiorcy Nazwa nadawcy Nazwa transferu diff --git a/shared/src/commonMain/composeResources/values-pt/strings.xml b/shared/src/commonMain/composeResources/values-pt/strings.xml index 1e2b507..d40618d 100644 --- a/shared/src/commonMain/composeResources/values-pt/strings.xml +++ b/shared/src/commonMain/composeResources/values-pt/strings.xml @@ -84,12 +84,15 @@ Partilhar diagnósticos É necessário acesso à câmara para ler um código QR. Não foi possível carregar as informações do dispositivo. + Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente. O VniDrop não conseguiu aceder aos ficheiros ou à pasta selecionados. Verifique as permissões e tente novamente. Algo correu mal. Tente novamente. O VniDrop não conseguiu concluir o arranque. Feche a app e tente novamente. + Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente. Não foi possível ler este convite. Peça um novo ao remetente. Esse convite está vazio. Tente abri-lo novamente. A biblioteca nativa do VniDrop está em falta nesta compilação. + O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente. Não foi possível utilizar esta etiqueta NFC. Tente outra. O remetente não aprovou esta transferência, ou foi recusada. O VniDrop não conseguiu guardar os dados da transferência neste dispositivo. @@ -97,7 +100,8 @@ Selecione pelo menos um item para partilhar. O VniDrop não conseguiu abrir os seus sockets de rede neste dispositivo. O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos. - Não foi possível concluir a transferência. Verifique a sua ligação e tente novamente. + Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente. + Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente. Nome do destinatário Nome do remetente Nome da transferência diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml index 44894c0..5e271cb 100644 --- a/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -84,12 +84,15 @@ Делиться диагностикой Для сканирования QR-кода требуется доступ к камере. Не удалось загрузить сведения об устройстве. + В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл. VniDrop не удалось получить доступ к выбранным файлам или папке. Проверьте разрешения и повторите попытку. Что-то пошло не так. Повторите попытку. VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку. + Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова. Не удалось прочитать это приглашение. Попросите отправителя прислать новое. Это приглашение пустое. Попробуйте открыть его снова. В этой сборке отсутствует нативная библиотека VniDrop. + VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку. Не удалось использовать эту NFC-метку. Попробуйте другую. Отправитель не одобрил эту передачу, или она была отклонена. VniDrop не удалось сохранить данные передачи на этом устройстве. @@ -97,7 +100,8 @@ Выберите хотя бы один объект для отправки. VniDrop не удалось открыть сетевые сокеты на этом устройстве. VniDrop ещё запускается. Откройте приглашение снова через мгновение. - Не удалось завершить передачу. Проверьте подключение и повторите попытку. + Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку. + Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова. Имя получателя Имя отправителя Название передачи diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 7ccbd3b..7767bf6 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -84,12 +84,15 @@ Share diagnostics Camera access is required to scan a QR code. Could not load device information. + A file with the same name already exists in the destination. Choose another folder or remove the existing file. VniDrop could not access the selected files or folder. Check permissions and try again. Something went wrong. Try again. VniDrop could not finish starting up. Close the app and try again. + Some transfer information is invalid. Review your selection or ask the sender to share again. This invitation could not be read. Ask the sender for a new one. That invitation is empty. Try opening it again. The native VniDrop library is missing from this build. + VniDrop could not reach the sender. Check the connection on both devices and try again. This NFC tag could not be used. Try another tag. The sender has not approved this transfer, or it was refused. VniDrop could not save transfer data on this device. @@ -97,7 +100,8 @@ Select at least one item to share. VniDrop could not open its network sockets on this device. VniDrop is still starting. Open the invitation again in a moment. - The transfer could not be completed. Check your connection and try again. + There is not enough storage space to save this transfer. Free up space and try again. + The transfer data could not be processed. Ask the sender to share it again. Receiver name Sender name Transfer name diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt index 495f5f6..1aab08d 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/feature/receive/ReceiveViewModel.kt @@ -16,6 +16,7 @@ import com.vnidrop.app.ui.feedback.UiMessage import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiText +import com.vnidrop.app.ui.feedback.canRetryWithoutChangingInput import com.vnidrop.app.ui.feedback.isUserCancellation import com.vnidrop.app.ui.feedback.toUiText import kotlinx.coroutines.flow.MutableStateFlow @@ -206,8 +207,12 @@ class ReceiveViewModel( UiMessage( text = uiText, tone = UiMessageTone.Error, - actionLabel = UiText.Resource(Res.string.button_retry), - onAction = { receive() }, + actionLabel = if (error.canRetryWithoutChangingInput()) { + UiText.Resource(Res.string.button_retry) + } else { + null + }, + onAction = if (error.canRetryWithoutChangingInput()) ({ receive() }) else null, ), ) }, diff --git a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt index 8a2cf98..b9e0ae1 100644 --- a/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt +++ b/shared/src/commonMain/kotlin/com/vnidrop/app/ui/feedback/UserFacingError.kt @@ -3,9 +3,11 @@ package com.vnidrop.app.ui.feedback import uniffi.vnidrop.VnidropException import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.error_device_info +import vnidrop.shared.generated.resources.error_destination_exists import vnidrop.shared.generated.resources.error_filesystem import vnidrop.shared.generated.resources.error_generic import vnidrop.shared.generated.resources.error_initialization +import vnidrop.shared.generated.resources.error_invalid_input import vnidrop.shared.generated.resources.error_invalid_ticket import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.error_missing_native_library @@ -15,8 +17,10 @@ import vnidrop.shared.generated.resources.error_selection_failed import vnidrop.shared.generated.resources.error_socket_bind import vnidrop.shared.generated.resources.error_camera import vnidrop.shared.generated.resources.error_nfc +import vnidrop.shared.generated.resources.error_network import vnidrop.shared.generated.resources.error_share_empty import vnidrop.shared.generated.resources.error_starting_up +import vnidrop.shared.generated.resources.error_storage_full import vnidrop.shared.generated.resources.error_transfer /** @@ -29,8 +33,14 @@ fun Throwable.toUiText(): UiText = is VnidropException.Ticket -> UiText.Resource(Res.string.error_invalid_ticket) is VnidropException.Permission -> UiText.Resource(Res.string.error_permission) is VnidropException.Filesystem -> UiText.Resource(Res.string.error_filesystem) + is VnidropException.FilesystemPermission -> UiText.Resource(Res.string.error_filesystem) + is VnidropException.DestinationExists -> UiText.Resource(Res.string.error_destination_exists) + is VnidropException.StorageFull -> UiText.Resource(Res.string.error_storage_full) + is VnidropException.Network -> UiText.Resource(Res.string.error_network) is VnidropException.Transfer -> transferUiText(reason) is VnidropException.Repository -> UiText.Resource(Res.string.error_repository) + is VnidropException.Cancelled -> UiText.Resource(Res.string.error_generic) + is VnidropException.InvalidInput -> UiText.Resource(Res.string.error_invalid_input) is VnidropException.Initialization -> initializationUiText(reason) is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic) else -> reasonHints(technicalDetail()) ?: UiText.Resource(Res.string.error_generic) @@ -38,6 +48,7 @@ fun Throwable.toUiText(): UiText = /** User intentionally backed out of a flow — do not treat as a failure snackbar. */ fun Throwable.isUserCancellation(): Boolean { + if (this is VnidropException.Cancelled) return true val haystack = technicalDetail().lowercase() if (haystack.isBlank()) return false return haystack.contains("cancelled") || @@ -46,15 +57,26 @@ fun Throwable.isUserCancellation(): Boolean { haystack.contains("user canceled") } +fun Throwable.canRetryWithoutChangingInput(): Boolean = + this !is VnidropException.FilesystemPermission && + this !is VnidropException.DestinationExists && + this !is VnidropException.InvalidInput + /** Prefer [VnidropException.reason] when present; else [Throwable.message]. */ fun Throwable.technicalDetail(): String = when (this) { is VnidropException.Initialization -> reason is VnidropException.Ticket -> reason is VnidropException.Filesystem -> reason + is VnidropException.FilesystemPermission -> reason + is VnidropException.DestinationExists -> reason + is VnidropException.StorageFull -> reason + is VnidropException.Network -> reason is VnidropException.Transfer -> reason is VnidropException.Permission -> reason is VnidropException.Repository -> reason + is VnidropException.Cancelled -> reason + is VnidropException.InvalidInput -> reason is VnidropException.Internal -> reason else -> message.orEmpty() } diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt index b50186b..8ff7f49 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/feature/ViewModelsTest.kt @@ -50,9 +50,11 @@ import kotlin.test.assertContentEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import uniffi.vnidrop.VnidropException import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.button_show_in_files import vnidrop.shared.generated.resources.error_permission +import vnidrop.shared.generated.resources.error_destination_exists import vnidrop.shared.generated.resources.receive_open_files_failed @OptIn(ExperimentalCoroutinesApi::class) @@ -560,6 +562,29 @@ class ViewModelsTest { ) } + @Test + fun receiveViewModelDoesNotOfferBlindRetryForDestinationCollision() = runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + val core = FakeCoreGateway().apply { + mutableState.value = mutableState.value.copy(isInitialized = true) + inspectionResult = Result.success(sampleTicketInspection()) + receiveResult = Result.failure(VnidropException.DestinationExists("target exists")) + } + val messages = UiMessageController() + val viewModel = ReceiveViewModel(core, FakeFileSystemService(folder), preferences(), messages) + advanceUntilIdle() + viewModel.onInvitationResult(com.vnidrop.app.feature.receive.ReceiveMethod.QrCode, Result.success("ticket")) + advanceUntilIdle() + + viewModel.receive() + advanceUntilIdle() + val message = messages.messages.first() + + assertEquals(UiText.Resource(Res.string.error_destination_exists), message.text) + assertEquals(null, message.actionLabel) + assertEquals(null, message.onAction) + } + @Test fun receiveViewModelCancelUsesActiveTransferId() = runTest { Dispatchers.setMain(StandardTestDispatcher(testScheduler)) diff --git a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt index 59e96f3..e5b2f52 100644 --- a/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt +++ b/shared/src/commonTest/kotlin/com/vnidrop/app/ui/feedback/UserFacingErrorTest.kt @@ -7,8 +7,10 @@ import kotlin.test.assertTrue import uniffi.vnidrop.VnidropException import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.error_filesystem +import vnidrop.shared.generated.resources.error_destination_exists import vnidrop.shared.generated.resources.error_generic import vnidrop.shared.generated.resources.error_initialization +import vnidrop.shared.generated.resources.error_invalid_input import vnidrop.shared.generated.resources.error_invalid_ticket import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.error_missing_native_library @@ -18,8 +20,10 @@ import vnidrop.shared.generated.resources.error_selection_failed import vnidrop.shared.generated.resources.error_socket_bind import vnidrop.shared.generated.resources.error_camera import vnidrop.shared.generated.resources.error_nfc +import vnidrop.shared.generated.resources.error_network import vnidrop.shared.generated.resources.error_share_empty import vnidrop.shared.generated.resources.error_starting_up +import vnidrop.shared.generated.resources.error_storage_full import vnidrop.shared.generated.resources.error_transfer class UserFacingErrorTest { @@ -37,6 +41,26 @@ class UserFacingErrorTest { UiText.Resource(Res.string.error_filesystem), VnidropException.Filesystem("permission denied opening path").toUiText(), ) + assertEquals( + UiText.Resource(Res.string.error_filesystem), + VnidropException.FilesystemPermission("folder is read-only").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_destination_exists), + VnidropException.DestinationExists("destination already exists").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_storage_full), + VnidropException.StorageFull("no space left").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_network), + VnidropException.Network("connection reset").toUiText(), + ) + assertEquals( + UiText.Resource(Res.string.error_invalid_input), + VnidropException.InvalidInput("invalid collection name").toUiText(), + ) assertEquals( UiText.Resource(Res.string.error_transfer), VnidropException.Transfer("connection reset").toUiText(), @@ -119,6 +143,15 @@ class UserFacingErrorTest { assertTrue(IllegalStateException("QR scanning was cancelled").isUserCancellation()) assertTrue(IllegalStateException("NFC writing was cancelled").isUserCancellation()) assertTrue(VnidropException.Transfer("transfer cancelled by user").isUserCancellation()) + assertTrue(VnidropException.Cancelled("cancel requested").isUserCancellation()) assertFalse(IllegalStateException("sender refused").isUserCancellation()) } + + @Test + fun retryRequiresChangingCollisionOrInvalidInput() { + assertFalse(VnidropException.FilesystemPermission("read-only").canRetryWithoutChangingInput()) + assertFalse(VnidropException.DestinationExists("target exists").canRetryWithoutChangingInput()) + assertFalse(VnidropException.InvalidInput("bad path").canRetryWithoutChangingInput()) + assertTrue(VnidropException.Network("offline").canRetryWithoutChangingInput()) + } }