Merge pull request #27 from sudosylabs/fix/typed-error-propagation

fix(core): preserve typed transfer failures
This commit is contained in:
Hammed Abass
2026-07-22 21:00:06 +02:00
committed by GitHub
28 changed files with 950 additions and 170 deletions

View File

@@ -1,4 +1,5 @@
import XCTest import XCTest
import VnidropCore
@testable import VniDrop @testable import VniDrop
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`. /// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
@@ -50,4 +51,15 @@ final class UserFacingErrorTests: XCTestCase {
func testToUiTextFallsBackToGeneric() { func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.message("something entirely unexpected").toUiText(), .resource("error_generic")) 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)
}
} }

View File

@@ -192,8 +192,8 @@ final class ReceiveModel: ObservableObject {
messages.tryShow(UiMessage( messages.tryShow(UiMessage(
text: uiText, text: uiText,
tone: .error, tone: .error,
actionLabel: .resource("button_retry"), actionLabel: error.canRetryWithoutChangingInput ? .resource("button_retry") : nil,
onAction: { self.receive() } onAction: error.canRetryWithoutChangingInput ? { self.receive() } : nil
)) ))
} }
} }

View File

@@ -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 unaltra 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": { "error_device_info": {
"comment": "Error: device information could not be loaded.", "comment": "Error: device information could not be loaded.",
"extractionState": "manual", "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 à lexpé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": { "error_invalid_ticket": {
"comment": "Error: the invitation/ticket could not be parsed.", "comment": "Error: the invitation/ticket could not be parsed.",
"extractionState": "manual", "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 na pas pu joindre lexpé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": { "error_nfc": {
"comment": "Error: the NFC tag could not be read/used.", "comment": "Error: the NFC tag could not be read/used.",
"extractionState": "manual", "extractionState": "manual",
@@ -5949,62 +6129,122 @@
} }
} }
}, },
"error_transfer": { "error_storage_full": {
"comment": "Error: the transfer could not be completed.", "comment": "Error: the destination does not have enough free storage.",
"extractionState": "manual", "extractionState": "manual",
"localizations": { "localizations": {
"de": { "de": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "en": {
"stringUnit": { "stringUnit": {
"state": "translated", "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": { "es": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "fr": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Le transfert na pas pu être terminé. Vérifiez votre connexion et réessayez." "value": "Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez."
} }
}, },
"it": { "it": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "nl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pl": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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": { "pt": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "state": "needs_review",
"value": "Não foi possível concluir a transferência. Verifique a sua ligã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": { "ru": {
"stringUnit": { "stringUnit": {
"state": "needs_review", "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 nont pas pu être traitées. Demandez à lexpé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": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
} }
} }
} }

View File

@@ -13,10 +13,22 @@ extension Error {
return .resource("error_permission") return .resource("error_permission")
case .Filesystem: case .Filesystem:
return .resource("error_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): case .Transfer(let reason):
return transferUiText(reason) return transferUiText(reason)
case .Repository: case .Repository:
return .resource("error_repository") return .resource("error_repository")
case .Cancelled:
return .resource("error_generic")
case .InvalidInput:
return .resource("error_invalid_input")
case .Initialization(let reason): case .Initialization(let reason):
return initializationUiText(reason) return initializationUiText(reason)
case .Internal(let reason): case .Internal(let reason):
@@ -28,6 +40,7 @@ extension Error {
/// True when the user intentionally backed out of a flow. /// True when the user intentionally backed out of a flow.
var isUserCancellation: Bool { var isUserCancellation: Bool {
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
let haystack = technicalDetail.lowercased() let haystack = technicalDetail.lowercased()
if haystack.isEmpty { if haystack.isEmpty {
// URLError / CocoaError cancellation without a message. // URLError / CocoaError cancellation without a message.
@@ -44,13 +57,23 @@ extension Error {
var technicalDetail: String { var technicalDetail: String {
if let vni = self as? VnidropError { if let vni = self as? VnidropError {
switch vni { switch vni {
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
.Transfer(let r), .Permission(let r), .Repository(let r), .Internal(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 r
} }
} }
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription 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 { private func transferUiText(_ reason: String) -> UiText {

View File

@@ -8,12 +8,24 @@ pub enum VnidropError {
Ticket { reason: String }, Ticket { reason: String },
#[error("filesystem error: {reason}")] #[error("filesystem error: {reason}")]
Filesystem { reason: String }, 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}")] #[error("transfer error: {reason}")]
Transfer { reason: String }, Transfer { reason: String },
#[error("permission error: {reason}")] #[error("permission error: {reason}")]
Permission { reason: String }, Permission { reason: String },
#[error("repository error: {reason}")] #[error("repository error: {reason}")]
Repository { reason: String }, Repository { reason: String },
#[error("operation cancelled: {reason}")]
Cancelled { reason: String },
#[error("invalid input: {reason}")]
InvalidInput { reason: String },
#[error("internal error: {reason}")] #[error("internal error: {reason}")]
Internal { reason: String }, Internal { reason: String },
} }
@@ -32,42 +44,138 @@ impl VnidropError {
} }
pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn filesystem(error: impl Into<anyhow::Error>) -> Self {
Self::Filesystem { let error = error.into();
reason: error.into().to_string(), Self::classify(error, |reason| Self::Filesystem { reason })
} }
pub(crate) fn network(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::Network { reason })
} }
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
Self::Transfer { let error = error.into();
reason: error.into().to_string(), Self::classify(error, |reason| Self::Transfer { reason })
}
} }
pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn permission(error: impl Into<anyhow::Error>) -> Self {
Self::Permission { Self::classify(error.into(), |reason| Self::Permission { reason })
reason: error.into().to_string(),
}
} }
pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self { pub(crate) fn repository(error: impl Into<anyhow::Error>) -> Self {
Self::Repository { Self::from_error(error.into(), |reason| Self::Repository { reason })
reason: error.into().to_string(), }
pub(crate) fn cancelled(reason: impl Into<String>) -> Self {
Self::Cancelled {
reason: reason.into(),
}
}
pub(crate) fn invalid_input(error: impl Into<anyhow::Error>) -> Self {
Self::from_error(error.into(), |reason| Self::InvalidInput { reason })
}
pub(crate) fn internal(error: impl Into<anyhow::Error>) -> 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::<Self>()) {
return existing.with_reason(reason);
}
if let Some(io_error) = error
.chain()
.find_map(|cause| cause.downcast_ref::<io::Error>())
{
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::<sqlx::Error>().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::<Self>()) {
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<anyhow::Error> for VnidropError { impl From<anyhow::Error> for VnidropError {
fn from(error: anyhow::Error) -> Self { fn from(error: anyhow::Error) -> Self {
Self::Internal { Self::classify(error, |reason| Self::Internal { reason })
reason: error.to_string(),
}
} }
} }
impl From<io::Error> for VnidropError { impl From<io::Error> for VnidropError {
fn from(error: io::Error) -> Self { fn from(error: io::Error) -> Self {
Self::Filesystem { Self::filesystem(error)
reason: error.to_string(),
}
} }
} }

View File

@@ -69,7 +69,11 @@ impl AtomicOutputFile {
} }
cleanup_stale_temporary_files(parent, STALE_PART_AGE)?; cleanup_stale_temporary_files(parent, STALE_PART_AGE)?;
if std::fs::symlink_metadata(&target).is_ok() { 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 let final_name = target

View File

@@ -21,6 +21,7 @@ use crate::{
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedLocatorKind, TransferAccessMode,
TransferMetadata, TransferMetadata,
}, },
error::VnidropError,
filesystem::{ filesystem::{
validated_relative_string, wait_for_writer, write_stream_to_blocking_writer, validated_relative_string, wait_for_writer, write_stream_to_blocking_writer,
AtomicOutputFile, AtomicOutputFile,
@@ -52,7 +53,7 @@ pub(super) struct OutputSinkFile<'a> {
impl<'a> OutputSinkFile<'a> { impl<'a> OutputSinkFile<'a> {
fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result<Self> { fn start(sink: &'a dyn ReceiveOutputSink, relative_path: String) -> Result<Self> {
sink.start_file(relative_path.clone()) sink.start_file(relative_path.clone())
.map_err(|error| anyhow::anyhow!(error.to_string()))?; .map_err(anyhow::Error::new)?;
Ok(Self { Ok(Self {
sink, sink,
relative_path, relative_path,
@@ -63,7 +64,7 @@ impl<'a> OutputSinkFile<'a> {
fn write(&self, bytes: Vec<u8>) -> Result<()> { fn write(&self, bytes: Vec<u8>) -> Result<()> {
self.sink self.sink
.write_chunk(self.relative_path.clone(), bytes) .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<()> { fn finish(mut self) -> Result<()> {
@@ -73,7 +74,7 @@ impl<'a> OutputSinkFile<'a> {
self.terminal = true; self.terminal = true;
self.sink self.sink
.finish_file(self.relative_path.clone()) .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> { impl<'a> OutputSinkFileV2<'a> {
fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result<Self> { fn start(sink: &'a dyn ReceiveOutputSinkV2, relative_path: String) -> Result<Self> {
sink.start_file(relative_path.clone()) sink.start_file(relative_path.clone())
.map_err(|error| anyhow::anyhow!(error.to_string()))?; .map_err(anyhow::Error::new)?;
Ok(Self { Ok(Self {
sink, sink,
relative_path, relative_path,
@@ -115,14 +116,14 @@ impl<'a> OutputSinkFileV2<'a> {
fn write(&self, bytes: Vec<u8>) -> Result<()> { fn write(&self, bytes: Vec<u8>) -> Result<()> {
self.sink self.sink
.write_chunk(self.relative_path.clone(), bytes) .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<crate::api::PublishedOutput> { fn finish(mut self) -> Result<crate::api::PublishedOutput> {
self.terminal = true; self.terminal = true;
self.sink self.sink
.finish_file(self.relative_path.clone()) .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 .transfer_slots
.acquire() .acquire()
.await .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) let parsed = match parse_transfer_ticket_with_limits(&ticket, &self.limits)
.context("failed to parse transfer ticket") .context("failed to parse transfer ticket")
{ {
@@ -205,7 +207,8 @@ impl CoreInner {
}; };
let transfer_id = parsed.metadata.transfer_id; let transfer_id = parsed.metadata.transfer_id;
self.persist_receive_start(transfer_id, &parsed, receiver_name.as_deref()) 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 // Cancellation is cooperative: it stops our receive future and marks
// local state while lower-level Iroh work unwinds naturally. // local state while lower-level Iroh work unwinds naturally.
let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
@@ -221,8 +224,10 @@ impl CoreInner {
); );
let (result, cancelled) = tokio::select! { let (result, cancelled) = tokio::select! {
result = self.receive_inner(transfer_id, parsed, target, receiver_name) => (result, false), result = self.receive_inner(transfer_id, parsed, target, receiver_name) => {
_ = &mut shutdown_rx => (Err(anyhow::anyhow!("transfer cancelled")), true), (result.map_err(VnidropError::transfer), false)
},
_ = &mut shutdown_rx => (Err(VnidropError::cancelled("transfer cancelled")), true),
}; };
self.active_transfers self.active_transfers
@@ -238,7 +243,7 @@ impl CoreInner {
"receive", "receive",
"error", "error",
"failed", "failed",
json!({ "reason": error.to_string() }), json!({ "code": error.code(), "reason": error.reason() }),
); );
let _ = self let _ = self
.repository .repository
@@ -250,7 +255,7 @@ impl CoreInner {
.await; .await;
} }
} }
result result.map_err(anyhow::Error::new)
} }
pub(super) async fn receive_inner( pub(super) async fn receive_inner(
@@ -261,7 +266,9 @@ impl CoreInner {
receiver_name: Option<String>, receiver_name: Option<String>,
) -> Result<()> { ) -> Result<()> {
if let ReceiveTarget::Directory(output_dir) = &target { 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(); let sender_addr = parsed.blob_ticket.addr().clone();
@@ -278,14 +285,16 @@ impl CoreInner {
let connection = self let connection = self
.endpoint .endpoint
.connect(sender_addr.clone(), iroh_blobs::ALPN) .connect(sender_addr.clone(), iroh_blobs::ALPN)
.await?; .await
.map_err(VnidropError::network)?;
self.emit_transfer(transfer_id, "receive", "network", "connected", json!({})); self.emit_transfer(transfer_id, "receive", "network", "connected", json!({}));
let hash_and_format = parsed.blob_ticket.hash_and_format(); let hash_and_format = parsed.blob_ticket.hash_and_format();
let (_hash_seq, sizes) = let (_hash_seq, sizes) =
get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None) get_hash_seq_and_sizes(&connection, &hash_and_format.hash, 1024 * 1024 * 32, None)
.await .await
.context("failed to get file sizes")?; .context("failed to get file sizes")
.map_err(VnidropError::network)?;
let total_size = sizes let total_size = sizes
.iter() .iter()
.try_fold(0u64, |total, size| total.checked_add(*size)) .try_fold(0u64, |total, size| total.checked_add(*size))
@@ -331,7 +340,11 @@ impl CoreInner {
); );
} }
GetProgressItem::Done(_) => break, 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::Receiving,
TransferStatus::Done, TransferStatus::Done,
) )
.await?; .await
.map_err(VnidropError::repository)?;
drop(download_tag); drop(download_tag);
self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({})); self.emit_transfer(transfer_id, "receive", "lifecycle", "done", json!({}));
let sender_transfer_id = delivery_receipt.transfer_id; let sender_transfer_id = delivery_receipt.transfer_id;
@@ -395,7 +409,8 @@ impl CoreInner {
total_size: parsed.metadata.total_size, total_size: parsed.metadata.total_size,
access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired), access_mode: mode_to_storage(&TransferAccessMode::ApprovalRequired),
}) })
.await?; .await
.map_err(VnidropError::repository)?;
let metadata_json = let metadata_json =
serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null); serde_json::to_value(&parsed.metadata).unwrap_or(serde_json::Value::Null);
self.emit_transfer( self.emit_transfer(
@@ -433,8 +448,9 @@ impl CoreInner {
match client match client
.request_transfer(metadata, receiver_name) .request_transfer(metadata, receiver_name)
.await .await
.map_err(|error| anyhow::anyhow!("handshake request failed: {error}"))? .map_err(|error| {
{ VnidropError::network(anyhow::anyhow!("handshake request failed: {error}"))
})? {
HandshakeResponse::Approved { HandshakeResponse::Approved {
request_id, request_id,
token, token,
@@ -456,9 +472,10 @@ impl CoreInner {
token, token,
}) })
} }
HandshakeResponse::Denied { reason } => { HandshakeResponse::Denied { reason } => Err(VnidropError::permission(anyhow::anyhow!(
anyhow::bail!("transfer request was denied by sender: {reason}") "transfer request was denied by sender: {reason}"
} ))
.into()),
} }
} }
@@ -469,7 +486,11 @@ impl CoreInner {
target: ReceiveTarget, target: ReceiveTarget,
collection: Collection, collection: Collection,
) -> Result<()> { ) -> 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 { let received_transfer = ReceivedTransfer {
protocol_id: transfer_id, protocol_id: transfer_id,
local_id: &transfer_local_id, local_id: &transfer_local_id,
@@ -529,7 +550,8 @@ impl CoreInner {
self.limits.max_path_bytes 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::<io::Result<Option<Bytes>>>(2); let (tx, rx) = async_channel::bounded::<io::Result<Option<Bytes>>>(2);
let writer_task = std::thread::spawn(move || write_stream_to_blocking_writer(writer, rx)); 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(); let mut stream = self.store.export_ranges(hash, 0..u64::MAX).stream();
@@ -572,10 +594,16 @@ impl CoreInner {
tx.send(Ok(None)) tx.send(Ok(None))
.await .await
.map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?; .map_err(|_| anyhow::anyhow!("export writer closed for {relative_path}"))?;
let writer = wait_for_writer(writer_task).await??; let writer = wait_for_writer(writer_task)
tokio::task::spawn_blocking(move || writer.sync_all()).await??; .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(); let locator = pending_file.target().to_string_lossy().to_string();
pending_file.commit()?; pending_file.commit().map_err(VnidropError::filesystem)?;
self.repository self.repository
.record_received_artifact(ReceivedArtifactInsert { .record_received_artifact(ReceivedArtifactInsert {
transfer_local_id: transfer.local_id, transfer_local_id: transfer.local_id,
@@ -585,7 +613,8 @@ impl CoreInner {
locator: &locator, locator: &locator,
logical_size: exported, logical_size: exported,
}) })
.await?; .await
.map_err(VnidropError::repository)?;
Ok(()) Ok(())
} }
@@ -712,7 +741,8 @@ impl CoreInner {
locator: &published.locator, locator: &published.locator,
logical_size: exported, logical_size: exported,
}) })
.await?; .await
.map_err(VnidropError::repository)?;
Ok(()) Ok(())
} }
} }

View File

@@ -17,6 +17,7 @@ use crate::{
access_policy::mode_to_storage, access_policy::mode_to_storage,
api::TransferMetadata, api::TransferMetadata,
api::{ShareMetadataInput, ShareResult, ShareSource}, api::{ShareMetadataInput, ShareResult, ShareSource},
error::VnidropError,
filesystem::{ filesystem::{
collect_import_files_with_limits, default_collection_name, collect_import_files_with_limits, default_collection_name,
read_stream_from_blocking_reader, TransferImport, read_stream_from_blocking_reader, TransferImport,
@@ -37,22 +38,29 @@ impl CoreInner {
.transfer_slots .transfer_slots
.acquire() .acquire()
.await .await
.context("transfer limiter is closed")?; .context("transfer limiter is closed")
.map_err(VnidropError::internal)?;
let transfer_id = metadata.transfer_id; let transfer_id = metadata.transfer_id;
if sources.is_empty() { 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 { if sources.len() as u64 > self.limits.max_sources {
anyhow::bail!( return Err(VnidropError::invalid_input(anyhow::anyhow!(
"source count {} exceeds limit {}", "source count {} exceeds limit {}",
sources.len(), sources.len(),
self.limits.max_sources self.limits.max_sources
); ))
.into());
} }
self.limits 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 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 self.repository
.insert_transfer(TransferUpsert { .insert_transfer(TransferUpsert {
transfer_id, transfer_id,
@@ -66,7 +74,8 @@ impl CoreInner {
total_size: 0, total_size: 0,
access_mode: mode_to_storage(&metadata.access_mode), access_mode: mode_to_storage(&metadata.access_mode),
}) })
.await?; .await
.map_err(VnidropError::repository)?;
let (cancel, mut cancelled) = oneshot::channel(); let (cancel, mut cancelled) = oneshot::channel();
self.active_transfers self.active_transfers
.lock() .lock()
@@ -79,8 +88,10 @@ impl CoreInner {
}, },
); );
let (result, was_cancelled) = tokio::select! { let (result, was_cancelled) = tokio::select! {
result = self.share_files_inner(sources, metadata) => (result, false), result = self.share_files_inner(sources, metadata) => {
_ = &mut cancelled => (Err(anyhow::anyhow!("transfer cancelled")), true), (result.map_err(VnidropError::transfer), false)
},
_ = &mut cancelled => (Err(VnidropError::cancelled("transfer cancelled")), true),
}; };
self.active_transfers self.active_transfers
.lock() .lock()
@@ -95,7 +106,7 @@ impl CoreInner {
"send", "send",
"error", "error",
"failed", "failed",
json!({ "reason": error.to_string() }), json!({ "code": error.code(), "reason": error.reason() }),
); );
let _ = self let _ = self
.repository .repository
@@ -107,7 +118,7 @@ impl CoreInner {
.await; .await;
} }
} }
result result.map_err(anyhow::Error::new)
} }
pub(super) async fn share_files_inner( pub(super) async fn share_files_inner(
@@ -145,7 +156,8 @@ impl CoreInner {
let local_id = self let local_id = self
.repository .repository
.transfer_local_id(metadata.transfer_id) .transfer_local_id(metadata.transfer_id)
.await?; .await
.map_err(VnidropError::repository)?;
let tag_name = share_tag_name(&local_id); let tag_name = share_tag_name(&local_id);
self.store self.store
.tags() .tags()
@@ -175,7 +187,7 @@ impl CoreInner {
.await .await
{ {
let _ = self.store.tags().delete(&tag_name).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 // Map root + every collection member so provider ACL cannot fail-open
// on child blob hashes that are not the collection root. // on child blob hashes that are not the collection root.

View File

@@ -1,5 +1,7 @@
#[path = "tests/access_policy.rs"] #[path = "tests/access_policy.rs"]
mod access_policy_tests; mod access_policy_tests;
#[path = "tests/error.rs"]
mod error_tests;
#[path = "tests/filesystem.rs"] #[path = "tests/filesystem.rs"]
mod filesystem_tests; mod filesystem_tests;
#[path = "tests/handshake.rs"] #[path = "tests/handshake.rs"]

View File

@@ -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 { .. }));
}

View File

@@ -8,6 +8,29 @@ use support::{
receive_with_sink_response, receive_with_sink_v2_response, share_path, receive_with_sink_response, receive_with_sink_v2_response, share_path,
wait_for_receiver_request, MemoryOutputSink, TestNode, 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<u8>) -> Result<(), VnidropError> {
unreachable!("a rejected file must not be written")
}
fn finish_file(&self, _relative_path: String) -> Result<PublishedOutput, VnidropError> {
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] #[test]
fn versioned_sink_records_published_locator() { fn versioned_sink_records_published_locator() {
@@ -89,10 +112,45 @@ fn reports_output_sink_write_failure() {
) )
.unwrap_err(); .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")); 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] #[test]
fn cancellation_during_export_aborts_open_sink_file() { fn cancellation_during_export_aborts_open_sink_file() {
// Gate the first write so export is mid-file when cancel runs. A large // Gate the first write so export is mid-file when cancel runs. A large

View File

@@ -292,12 +292,10 @@ pub fn receive_with_response(
ticket: String, ticket: String,
output_dir: &Path, output_dir: &Path,
accepted: bool, accepted: bool,
) -> Result<(), String> { ) -> Result<(), VnidropError> {
let output_dir = output_dir.to_string_lossy().to_string(); let output_dir = output_dir.to_string_lossy().to_string();
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
receiver receiver.receive(ticket, output_dir, Some("receiver".to_string()))
.receive(ticket, output_dir, Some("receiver".to_string()))
.map_err(|error| error.to_string())
}); });
respond_to_pending_request(sender, transfer_id, accepted); respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap() handle.join().unwrap()
@@ -310,11 +308,9 @@ pub fn receive_with_sink_response(
ticket: String, ticket: String,
output_sink: Arc<dyn ReceiveOutputSink>, output_sink: Arc<dyn ReceiveOutputSink>,
accepted: bool, accepted: bool,
) -> Result<(), String> { ) -> Result<(), VnidropError> {
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
receiver receiver.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
.receive_with_output_sink(ticket, output_sink, Some("receiver".to_string()))
.map_err(|error| error.to_string())
}); });
respond_to_pending_request(sender, transfer_id, accepted); respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap() handle.join().unwrap()
@@ -327,11 +323,9 @@ pub fn receive_with_sink_v2_response(
ticket: String, ticket: String,
output_sink: Arc<dyn ReceiveOutputSinkV2>, output_sink: Arc<dyn ReceiveOutputSinkV2>,
accepted: bool, accepted: bool,
) -> Result<(), String> { ) -> Result<(), VnidropError> {
let handle = std::thread::spawn(move || { let handle = std::thread::spawn(move || {
receiver receiver.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string()))
.receive_with_output_sink_v2(ticket, output_sink, Some("receiver".to_string()))
.map_err(|error| error.to_string())
}); });
respond_to_pending_request(sender, transfer_id, accepted); respond_to_pending_request(sender, transfer_id, accepted);
handle.join().unwrap() handle.join().unwrap()

View File

@@ -1,6 +1,7 @@
mod support; mod support;
use support::{receive_with_response, share_path, TestNode}; use support::{receive_with_response, share_path, TestNode};
use vnidrop::VnidropError;
#[test] #[test]
fn transfers_file_between_two_cores() { fn transfers_file_between_two_cores() {
@@ -98,7 +99,7 @@ fn receive_refuses_to_overwrite_existing_destination() {
let receiver = TestNode::new(); let receiver = TestNode::new();
let share = share_path(&sender.core, &source_path, 27, "existing.txt", false); let share = share_path(&sender.core, &source_path, 27, "existing.txt", false);
assert!(receive_with_response( let error = receive_with_response(
&sender.core, &sender.core,
share.transfer_id, share.transfer_id,
receiver.core.arc(), receiver.core.arc(),
@@ -106,7 +107,8 @@ fn receive_refuses_to_overwrite_existing_destination() {
output_dir.path(), output_dir.path(),
true, true,
) )
.is_err()); .unwrap_err();
assert!(matches!(error, VnidropError::DestinationExists { .. }));
assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content"); assert_eq!(std::fs::read(&output_path).unwrap(), b"keep content");
assert!(std::fs::read_dir(output_dir.path()) assert!(std::fs::read_dir(output_dir.path())
.unwrap() .unwrap()
@@ -115,4 +117,22 @@ fn receive_refuses_to_overwrite_existing_destination() {
.file_name() .file_name()
.to_string_lossy() .to_string_lossy()
.contains(".part"))); .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\"")
}));
} }

View File

@@ -1281,6 +1281,20 @@
"ru": "Не удалось загрузить сведения об устройстве." "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 unaltra 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": { "error_filesystem": {
"context": "Error: the selected files/folder could not be accessed.", "context": "Error: the selected files/folder could not be accessed.",
"translations": { "translations": {
@@ -1323,6 +1337,20 @@
"ru": "VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку." "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 à lexpé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": { "error_invalid_ticket": {
"context": "Error: the invitation/ticket could not be parsed.", "context": "Error: the invitation/ticket could not be parsed.",
"translations": { "translations": {
@@ -1365,6 +1393,20 @@
"ru": "В этой сборке отсутствует нативная библиотека VniDrop." "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 na pas pu joindre lexpé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": { "error_nfc": {
"context": "Error: the NFC tag could not be read/used.", "context": "Error: the NFC tag could not be read/used.",
"translations": { "translations": {
@@ -1463,18 +1505,32 @@
"ru": "VniDrop ещё запускается. Откройте приглашение снова через мгновение." "ru": "VniDrop ещё запускается. Откройте приглашение снова через мгновение."
} }
}, },
"error_transfer": { "error_storage_full": {
"context": "Error: the transfer could not be completed.", "context": "Error: the destination does not have enough free storage.",
"translations": { "translations": {
"en": "The transfer could not be completed. Check your connection and try again.", "en": "There is not enough storage space to save this transfer. Free up space and try again.",
"fr": "Le transfert na pas pu être terminé. Vérifiez votre connexion et réessayez.", "fr": "Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.",
"es": "No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo.", "es": "No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.",
"it": "Impossibile completare il trasferimento. Controlli la connessione e riprovi.", "it": "Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.",
"de": "Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.", "de": "Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.",
"pt": "Não foi possível concluir a transferência. Verifique a sua ligão e tente novamente.", "pt": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.",
"pl": "Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie.", "pl": "Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.",
"nl": "De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw.", "nl": "Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.",
"ru": "Не удалось завершить передачу. Проверьте подключение и повторите попытку." "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 nont pas pu être traitées. Demandez à lexpé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": { "field_receiver_name": {

View File

@@ -7,6 +7,8 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.system.ErrnoException
import android.system.OsConstants
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,7 +17,9 @@ import androidx.core.net.toUri
import uniffi.vnidrop.PublishedOutput import uniffi.vnidrop.PublishedOutput
import uniffi.vnidrop.ReceiveOutputSinkV2 import uniffi.vnidrop.ReceiveOutputSinkV2
import uniffi.vnidrop.ReceivedLocatorKind import uniffi.vnidrop.ReceivedLocatorKind
import uniffi.vnidrop.VnidropException
import java.io.File import java.io.File
import java.io.IOException
import java.io.OutputStream import java.io.OutputStream
import java.net.URLConnection import java.net.URLConnection
import java.util.UUID import java.util.UUID
@@ -248,6 +252,32 @@ private fun Context.expandShareDirectory(folder: PickedShareFile): List<PickedSh
return out return out
} }
private inline fun <T> 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. * Writes into the shared system Downloads collection via MediaStore.
* *
@@ -266,6 +296,7 @@ private class AndroidMediaStoreDownloadsSink(
private val resolver = context.contentResolver private val resolver = context.contentResolver
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
receiveSinkCall {
check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { check(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
"MediaStore Downloads requires Android 10 or newer" "MediaStore Downloads requires Android 10 or newer"
} }
@@ -273,8 +304,8 @@ private class AndroidMediaStoreDownloadsSink(
val parts = requireSafeRelativePathParts(relativePath) val parts = requireSafeRelativePathParts(relativePath)
val finalName = parts.last() val finalName = parts.last()
val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
check(!mediaStoreItemExists(finalName, relativeDir)) { if (mediaStoreItemExists(finalName, relativeDir)) {
"Destination already exists: $relativePath" throw VnidropException.DestinationExists("Destination already exists: $relativePath")
} }
val values = ContentValues().apply { val values = ContentValues().apply {
@@ -295,13 +326,16 @@ private class AndroidMediaStoreDownloadsSink(
} }
pending[relativePath] = PendingDocument(stream, uri) pending[relativePath] = PendingDocument(stream, uri)
} }
}
override fun writeChunk(relativePath: String, bytes: ByteArray) { override fun writeChunk(relativePath: String, bytes: ByteArray) {
receiveSinkCall {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes) 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") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.flush() document.stream.flush()
@@ -316,14 +350,16 @@ private class AndroidMediaStoreDownloadsSink(
resolver.delete(document.uri, null, null) resolver.delete(document.uri, null, null)
throw error 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) { override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return receiveSinkCall {
val document = pending.remove(relativePath) ?: return@receiveSinkCall
runCatching { document.stream.close() } runCatching { document.stream.close() }
resolver.delete(document.uri, null, null) resolver.delete(document.uri, null, null)
} }
}
private fun downloadsCollection(): Uri = private fun downloadsCollection(): Uri =
MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
@@ -390,11 +426,14 @@ private class AndroidTreeReceiveOutputSink(
private val pending = mutableMapOf<String, PendingDocument>() private val pending = mutableMapOf<String, PendingDocument>()
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
receiveSinkCall {
check(relativePath !in pending) { "Output stream is already open for $relativePath" } check(relativePath !in pending) { "Output stream is already open for $relativePath" }
// Defense in depth: Rust also validates, but sinks must reject traversal alone. // Defense in depth: Rust also validates, but sinks must reject traversal alone.
requireSafeRelativePathParts(relativePath) requireSafeRelativePathParts(relativePath)
val (parent, finalName) = resolveParent(relativePath) val (parent, finalName) = resolveParent(relativePath)
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } if (findChild(parent, finalName) != null) {
throw VnidropException.DestinationExists("Destination already exists: $relativePath")
}
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
val temporaryUri = DocumentsContract.createDocument( val temporaryUri = DocumentsContract.createDocument(
context.contentResolver, context.contentResolver,
@@ -406,17 +445,22 @@ private class AndroidTreeReceiveOutputSink(
?: error("Could not open output stream for $relativePath") ?: error("Could not open output stream for $relativePath")
pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName) pending[relativePath] = PendingDocument(stream, temporaryUri, parent, finalName)
} }
}
override fun writeChunk(relativePath: String, bytes: ByteArray) { override fun writeChunk(relativePath: String, bytes: ByteArray) {
receiveSinkCall {
val document = pending[relativePath] ?: error("Output stream is not open for $relativePath") val document = pending[relativePath] ?: error("Output stream is not open for $relativePath")
document.stream.write(bytes) 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") val document = pending.remove(relativePath) ?: error("Output stream is not open for $relativePath")
try { try {
document.stream.close() 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( val finalUri = checkNotNull(
DocumentsContract.renameDocument( DocumentsContract.renameDocument(
context.contentResolver, context.contentResolver,
@@ -424,7 +468,7 @@ private class AndroidTreeReceiveOutputSink(
document.finalName, document.finalName,
), ),
) { "Could not commit received file $relativePath" } ) { "Could not commit received file $relativePath" }
return PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString()) PublishedOutput(ReceivedLocatorKind.ANDROID_DOCUMENT, finalUri.toString())
} catch (error: Throwable) { } catch (error: Throwable) {
runCatching { document.stream.close() } runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
@@ -433,10 +477,12 @@ private class AndroidTreeReceiveOutputSink(
} }
override fun abortFile(relativePath: String, reason: String) { override fun abortFile(relativePath: String, reason: String) {
val document = pending.remove(relativePath) ?: return receiveSinkCall {
val document = pending.remove(relativePath) ?: return@receiveSinkCall
runCatching { document.stream.close() } runCatching { document.stream.close() }
DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri) DocumentsContract.deleteDocument(context.contentResolver, document.temporaryUri)
} }
}
private fun resolveParent(relativePath: String): Pair<Uri, String> { private fun resolveParent(relativePath: String): Pair<Uri, String> {
val parts = requireSafeRelativePathParts(relativePath) val parts = requireSafeRelativePathParts(relativePath)

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Diagnosedaten teilen</string> <string name="diagnostics_title">Diagnosedaten teilen</string>
<string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string> <string name="error_camera">Für das Scannen eines QR-Codes ist Kamerazugriff erforderlich.</string>
<string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string> <string name="error_device_info">Geräteinformationen konnten nicht geladen werden.</string>
<string name="error_destination_exists">Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.</string>
<string name="error_filesystem">VniDrop konnte nicht auf die ausgewählten Dateien oder den Ordner zugreifen. Überprüfen Sie die Berechtigungen und versuchen Sie es erneut.</string> <string name="error_filesystem">VniDrop konnte nicht auf die ausgewählten Dateien oder den Ordner zugreifen. Überprüfen Sie die Berechtigungen und versuchen Sie es erneut.</string>
<string name="error_generic">Etwas ist schiefgelaufen. Versuchen Sie es erneut.</string> <string name="error_generic">Etwas ist schiefgelaufen. Versuchen Sie es erneut.</string>
<string name="error_initialization">VniDrop konnte den Start nicht abschließen. Schließen Sie die App und versuchen Sie es erneut.</string> <string name="error_initialization">VniDrop konnte den Start nicht abschließen. Schließen Sie die App und versuchen Sie es erneut.</string>
<string name="error_invalid_input">Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen.</string>
<string name="error_invalid_ticket">Diese Einladung konnte nicht gelesen werden. Bitten Sie den Absender um eine neue.</string> <string name="error_invalid_ticket">Diese Einladung konnte nicht gelesen werden. Bitten Sie den Absender um eine neue.</string>
<string name="error_invitation_empty">Diese Einladung ist leer. Versuchen Sie, sie erneut zu öffnen.</string> <string name="error_invitation_empty">Diese Einladung ist leer. Versuchen Sie, sie erneut zu öffnen.</string>
<string name="error_missing_native_library">Die native VniDrop-Bibliothek fehlt in diesem Build.</string> <string name="error_missing_native_library">Die native VniDrop-Bibliothek fehlt in diesem Build.</string>
<string name="error_network">VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut.</string>
<string name="error_nfc">Dieses NFC-Tag konnte nicht verwendet werden. Versuchen Sie ein anderes.</string> <string name="error_nfc">Dieses NFC-Tag konnte nicht verwendet werden. Versuchen Sie ein anderes.</string>
<string name="error_permission">Der Absender hat diese Übertragung nicht genehmigt oder sie wurde abgelehnt.</string> <string name="error_permission">Der Absender hat diese Übertragung nicht genehmigt oder sie wurde abgelehnt.</string>
<string name="error_repository">VniDrop konnte die Übertragungsdaten auf diesem Gerät nicht speichern.</string> <string name="error_repository">VniDrop konnte die Übertragungsdaten auf diesem Gerät nicht speichern.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Wählen Sie mindestens ein Objekt zum Teilen aus.</string> <string name="error_share_empty">Wählen Sie mindestens ein Objekt zum Teilen aus.</string>
<string name="error_socket_bind">VniDrop konnte seine Netzwerk-Sockets auf diesem Gerät nicht öffnen.</string> <string name="error_socket_bind">VniDrop konnte seine Netzwerk-Sockets auf diesem Gerät nicht öffnen.</string>
<string name="error_starting_up">VniDrop startet noch. Öffnen Sie die Einladung gleich erneut.</string> <string name="error_starting_up">VniDrop startet noch. Öffnen Sie die Einladung gleich erneut.</string>
<string name="error_transfer">Die Übertragung konnte nicht abgeschlossen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.</string> <string name="error_storage_full">Zum Speichern dieser Übertragung ist nicht genügend Speicherplatz vorhanden. Geben Sie Speicherplatz frei und versuchen Sie es erneut.</string>
<string name="error_transfer">Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.</string>
<string name="field_receiver_name">Empfängername</string> <string name="field_receiver_name">Empfängername</string>
<string name="field_sender_name">Absendername</string> <string name="field_sender_name">Absendername</string>
<string name="field_transfer_name">Übertragungsname</string> <string name="field_transfer_name">Übertragungsname</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Compartir diagnósticos</string> <string name="diagnostics_title">Compartir diagnósticos</string>
<string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string> <string name="error_camera">Se necesita acceso a la cámara para escanear un código QR.</string>
<string name="error_device_info">No se pudo cargar la información del dispositivo.</string> <string name="error_device_info">No se pudo cargar la información del dispositivo.</string>
<string name="error_destination_exists">Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente.</string>
<string name="error_filesystem">VniDrop no pudo acceder a los archivos o la carpeta seleccionados. Compruebe los permisos e inténtelo de nuevo.</string> <string name="error_filesystem">VniDrop no pudo acceder a los archivos o la carpeta seleccionados. Compruebe los permisos e inténtelo de nuevo.</string>
<string name="error_generic">Algo salió mal. Inténtelo de nuevo.</string> <string name="error_generic">Algo salió mal. Inténtelo de nuevo.</string>
<string name="error_initialization">VniDrop no pudo terminar de iniciarse. Cierre la app e inténtelo de nuevo.</string> <string name="error_initialization">VniDrop no pudo terminar de iniciarse. Cierre la app e inténtelo de nuevo.</string>
<string name="error_invalid_input">Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla.</string>
<string name="error_invalid_ticket">No se pudo leer esta invitación. Pida al remitente una nueva.</string> <string name="error_invalid_ticket">No se pudo leer esta invitación. Pida al remitente una nueva.</string>
<string name="error_invitation_empty">Esa invitación está vacía. Intente abrirla de nuevo.</string> <string name="error_invitation_empty">Esa invitación está vacía. Intente abrirla de nuevo.</string>
<string name="error_missing_native_library">Falta la biblioteca nativa de VniDrop en esta versión.</string> <string name="error_missing_native_library">Falta la biblioteca nativa de VniDrop en esta versión.</string>
<string name="error_network">VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo.</string>
<string name="error_nfc">No se pudo usar esta etiqueta NFC. Pruebe con otra.</string> <string name="error_nfc">No se pudo usar esta etiqueta NFC. Pruebe con otra.</string>
<string name="error_permission">El remitente no ha aprobado esta transferencia, o fue rechazada.</string> <string name="error_permission">El remitente no ha aprobado esta transferencia, o fue rechazada.</string>
<string name="error_repository">VniDrop no pudo guardar los datos de la transferencia en este dispositivo.</string> <string name="error_repository">VniDrop no pudo guardar los datos de la transferencia en este dispositivo.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Seleccione al menos un elemento para compartir.</string> <string name="error_share_empty">Seleccione al menos un elemento para compartir.</string>
<string name="error_socket_bind">VniDrop no pudo abrir sus sockets de red en este dispositivo.</string> <string name="error_socket_bind">VniDrop no pudo abrir sus sockets de red en este dispositivo.</string>
<string name="error_starting_up">VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento.</string> <string name="error_starting_up">VniDrop todavía se está iniciando. Vuelva a abrir la invitación en un momento.</string>
<string name="error_transfer">No se pudo completar la transferencia. Compruebe su conexión e inténtelo de nuevo.</string> <string name="error_storage_full">No hay suficiente espacio de almacenamiento para guardar esta transferencia. Libere espacio e inténtelo de nuevo.</string>
<string name="error_transfer">No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.</string>
<string name="field_receiver_name">Nombre del destinatario</string> <string name="field_receiver_name">Nombre del destinatario</string>
<string name="field_sender_name">Nombre del remitente</string> <string name="field_sender_name">Nombre del remitente</string>
<string name="field_transfer_name">Nombre de la transferencia</string> <string name="field_transfer_name">Nombre de la transferencia</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Partager les diagnostics</string> <string name="diagnostics_title">Partager les diagnostics</string>
<string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string> <string name="error_camera">Laccès à la caméra est nécessaire pour scanner un QR code.</string>
<string name="error_device_info">Impossible de charger les informations de lappareil.</string> <string name="error_device_info">Impossible de charger les informations de lappareil.</string>
<string name="error_destination_exists">Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant.</string>
<string name="error_filesystem">VniDrop na pas pu accéder aux fichiers ou au dossier sélectionnés. Vérifiez les autorisations et réessayez.</string> <string name="error_filesystem">VniDrop na pas pu accéder aux fichiers ou au dossier sélectionnés. Vérifiez les autorisations et réessayez.</string>
<string name="error_generic">Une erreur est survenue. Réessayez.</string> <string name="error_generic">Une erreur est survenue. Réessayez.</string>
<string name="error_initialization">VniDrop na pas pu terminer son démarrage. Fermez lapp et réessayez.</string> <string name="error_initialization">VniDrop na pas pu terminer son démarrage. Fermez lapp et réessayez.</string>
<string name="error_invalid_input">Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à lexpéditeur de partager à nouveau.</string>
<string name="error_invalid_ticket">Cette invitation na pas pu être lue. Demandez-en une nouvelle à lexpéditeur.</string> <string name="error_invalid_ticket">Cette invitation na pas pu être lue. Demandez-en une nouvelle à lexpéditeur.</string>
<string name="error_invitation_empty">Cette invitation est vide. Essayez de louvrir à nouveau.</string> <string name="error_invitation_empty">Cette invitation est vide. Essayez de louvrir à nouveau.</string>
<string name="error_missing_native_library">La bibliothèque native de VniDrop est absente de cette version.</string> <string name="error_missing_native_library">La bibliothèque native de VniDrop est absente de cette version.</string>
<string name="error_network">VniDrop na pas pu joindre lexpéditeur. Vérifiez la connexion sur les deux appareils et réessayez.</string>
<string name="error_nfc">Ce tag NFC na pas pu être utilisé. Essayez-en un autre.</string> <string name="error_nfc">Ce tag NFC na pas pu être utilisé. Essayez-en un autre.</string>
<string name="error_permission">Lexpéditeur na pas approuvé ce transfert, ou il a été refusé.</string> <string name="error_permission">Lexpéditeur na pas approuvé ce transfert, ou il a été refusé.</string>
<string name="error_repository">VniDrop na pas pu enregistrer les données de transfert sur cet appareil.</string> <string name="error_repository">VniDrop na pas pu enregistrer les données de transfert sur cet appareil.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Sélectionnez au moins un élément à partager.</string> <string name="error_share_empty">Sélectionnez au moins un élément à partager.</string>
<string name="error_socket_bind">VniDrop na pas pu ouvrir ses sockets réseau sur cet appareil.</string> <string name="error_socket_bind">VniDrop na pas pu ouvrir ses sockets réseau sur cet appareil.</string>
<string name="error_starting_up">VniDrop démarre encore. Rouvrez linvitation dans un instant.</string> <string name="error_starting_up">VniDrop démarre encore. Rouvrez linvitation dans un instant.</string>
<string name="error_transfer">Le transfert na pas pu être terminé. Vérifiez votre connexion et réessayez.</string> <string name="error_storage_full">Lespace de stockage est insuffisant pour enregistrer ce transfert. Libérez de lespace et réessayez.</string>
<string name="error_transfer">Les données du transfert nont pas pu être traitées. Demandez à lexpéditeur de les partager à nouveau.</string>
<string name="field_receiver_name">Nom du destinataire</string> <string name="field_receiver_name">Nom du destinataire</string>
<string name="field_sender_name">Nom de lexpéditeur</string> <string name="field_sender_name">Nom de lexpéditeur</string>
<string name="field_transfer_name">Nom du transfert</string> <string name="field_transfer_name">Nom du transfert</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Condividi dati diagnostici</string> <string name="diagnostics_title">Condividi dati diagnostici</string>
<string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string> <string name="error_camera">Per scansionare un codice QR è necessario laccesso alla fotocamera.</string>
<string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string> <string name="error_device_info">Impossibile caricare le informazioni sul dispositivo.</string>
<string name="error_destination_exists">Nella destinazione esiste già un file con lo stesso nome. Scelga unaltra cartella o rimuova il file esistente.</string>
<string name="error_filesystem">VniDrop non ha potuto accedere ai file o alla cartella selezionati. Controlli le autorizzazioni e riprovi.</string> <string name="error_filesystem">VniDrop non ha potuto accedere ai file o alla cartella selezionati. Controlli le autorizzazioni e riprovi.</string>
<string name="error_generic">Qualcosa è andato storto. Riprovi.</string> <string name="error_generic">Qualcosa è andato storto. Riprovi.</string>
<string name="error_initialization">VniDrop non ha potuto completare lavvio. Chiuda lapp e riprovi.</string> <string name="error_initialization">VniDrop non ha potuto completare lavvio. Chiuda lapp e riprovi.</string>
<string name="error_invalid_input">Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo.</string>
<string name="error_invalid_ticket">Impossibile leggere questo invito. Ne chieda uno nuovo al mittente.</string> <string name="error_invalid_ticket">Impossibile leggere questo invito. Ne chieda uno nuovo al mittente.</string>
<string name="error_invitation_empty">Questo invito è vuoto. Provi ad aprirlo di nuovo.</string> <string name="error_invitation_empty">Questo invito è vuoto. Provi ad aprirlo di nuovo.</string>
<string name="error_missing_native_library">La libreria nativa di VniDrop non è presente in questa build.</string> <string name="error_missing_native_library">La libreria nativa di VniDrop non è presente in questa build.</string>
<string name="error_network">VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi.</string>
<string name="error_nfc">Impossibile usare questo tag NFC. Ne provi un altro.</string> <string name="error_nfc">Impossibile usare questo tag NFC. Ne provi un altro.</string>
<string name="error_permission">Il mittente non ha approvato questo trasferimento, oppure è stato rifiutato.</string> <string name="error_permission">Il mittente non ha approvato questo trasferimento, oppure è stato rifiutato.</string>
<string name="error_repository">VniDrop non ha potuto salvare i dati del trasferimento su questo dispositivo.</string> <string name="error_repository">VniDrop non ha potuto salvare i dati del trasferimento su questo dispositivo.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Selezioni almeno un elemento da condividere.</string> <string name="error_share_empty">Selezioni almeno un elemento da condividere.</string>
<string name="error_socket_bind">VniDrop non ha potuto aprire i suoi socket di rete su questo dispositivo.</string> <string name="error_socket_bind">VniDrop non ha potuto aprire i suoi socket di rete su questo dispositivo.</string>
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra linvito tra un momento.</string> <string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra linvito tra un momento.</string>
<string name="error_transfer">Impossibile completare il trasferimento. Controlli la connessione e riprovi.</string> <string name="error_storage_full">Lo spazio di archiviazione non è sufficiente per salvare il trasferimento. Liberi spazio e riprovi.</string>
<string name="error_transfer">Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.</string>
<string name="field_receiver_name">Nome del destinatario</string> <string name="field_receiver_name">Nome del destinatario</string>
<string name="field_sender_name">Nome del mittente</string> <string name="field_sender_name">Nome del mittente</string>
<string name="field_transfer_name">Nome del trasferimento</string> <string name="field_transfer_name">Nome del trasferimento</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Diagnostische gegevens delen</string> <string name="diagnostics_title">Diagnostische gegevens delen</string>
<string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string> <string name="error_camera">Voor het scannen van een QR-code is toegang tot de camera vereist.</string>
<string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string> <string name="error_device_info">Apparaatgegevens konden niet worden geladen.</string>
<string name="error_destination_exists">Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.</string>
<string name="error_filesystem">VniDrop kon geen toegang krijgen tot de geselecteerde bestanden of map. Controleer de machtigingen en probeer het opnieuw.</string> <string name="error_filesystem">VniDrop kon geen toegang krijgen tot de geselecteerde bestanden of map. Controleer de machtigingen en probeer het opnieuw.</string>
<string name="error_generic">Er is iets misgegaan. Probeer het opnieuw.</string> <string name="error_generic">Er is iets misgegaan. Probeer het opnieuw.</string>
<string name="error_initialization">VniDrop kon het opstarten niet voltooien. Sluit de app en probeer het opnieuw.</string> <string name="error_initialization">VniDrop kon het opstarten niet voltooien. Sluit de app en probeer het opnieuw.</string>
<string name="error_invalid_input">Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen.</string>
<string name="error_invalid_ticket">Deze uitnodiging kon niet worden gelezen. Vraag de afzender om een nieuwe.</string> <string name="error_invalid_ticket">Deze uitnodiging kon niet worden gelezen. Vraag de afzender om een nieuwe.</string>
<string name="error_invitation_empty">Die uitnodiging is leeg. Probeer deze opnieuw te openen.</string> <string name="error_invitation_empty">Die uitnodiging is leeg. Probeer deze opnieuw te openen.</string>
<string name="error_missing_native_library">De native VniDrop-bibliotheek ontbreekt in deze build.</string> <string name="error_missing_native_library">De native VniDrop-bibliotheek ontbreekt in deze build.</string>
<string name="error_network">VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw.</string>
<string name="error_nfc">Deze NFC-tag kon niet worden gebruikt. Probeer een andere.</string> <string name="error_nfc">Deze NFC-tag kon niet worden gebruikt. Probeer een andere.</string>
<string name="error_permission">De afzender heeft deze overdracht niet goedgekeurd, of deze is geweigerd.</string> <string name="error_permission">De afzender heeft deze overdracht niet goedgekeurd, of deze is geweigerd.</string>
<string name="error_repository">VniDrop kon de overdrachtsgegevens niet op dit apparaat bewaren.</string> <string name="error_repository">VniDrop kon de overdrachtsgegevens niet op dit apparaat bewaren.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Selecteer minstens één item om te delen.</string> <string name="error_share_empty">Selecteer minstens één item om te delen.</string>
<string name="error_socket_bind">VniDrop kon zijn netwerksockets niet openen op dit apparaat.</string> <string name="error_socket_bind">VniDrop kon zijn netwerksockets niet openen op dit apparaat.</string>
<string name="error_starting_up">VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw.</string> <string name="error_starting_up">VniDrop is nog aan het opstarten. Open de uitnodiging zo meteen opnieuw.</string>
<string name="error_transfer">De overdracht kon niet worden voltooid. Controleer uw verbinding en probeer het opnieuw.</string> <string name="error_storage_full">Er is onvoldoende opslagruimte om deze overdracht op te slaan. Maak ruimte vrij en probeer het opnieuw.</string>
<string name="error_transfer">De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.</string>
<string name="field_receiver_name">Naam van ontvanger</string> <string name="field_receiver_name">Naam van ontvanger</string>
<string name="field_sender_name">Naam van afzender</string> <string name="field_sender_name">Naam van afzender</string>
<string name="field_transfer_name">Naam van overdracht</string> <string name="field_transfer_name">Naam van overdracht</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Udostępniaj diagnostykę</string> <string name="diagnostics_title">Udostępniaj diagnostykę</string>
<string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string> <string name="error_camera">Do zeskanowania kodu QR wymagany jest dostęp do aparatu.</string>
<string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string> <string name="error_device_info">Nie udało się wczytać informacji o urządzeniu.</string>
<string name="error_destination_exists">W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.</string>
<string name="error_filesystem">VniDrop nie mógł uzyskać dostępu do wybranych plików lub folderu. Sprawdź uprawnienia i spróbuj ponownie.</string> <string name="error_filesystem">VniDrop nie mógł uzyskać dostępu do wybranych plików lub folderu. Sprawdź uprawnienia i spróbuj ponownie.</string>
<string name="error_generic">Coś poszło nie tak. Spróbuj ponownie.</string> <string name="error_generic">Coś poszło nie tak. Spróbuj ponownie.</string>
<string name="error_initialization">VniDrop nie mógł dokończyć uruchamiania. Zamknij aplikację i spróbuj ponownie.</string> <string name="error_initialization">VniDrop nie mógł dokończyć uruchamiania. Zamknij aplikację i spróbuj ponownie.</string>
<string name="error_invalid_input">Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie.</string>
<string name="error_invalid_ticket">Nie udało się odczytać tego zaproszenia. Poproś nadawcę o nowe.</string> <string name="error_invalid_ticket">Nie udało się odczytać tego zaproszenia. Poproś nadawcę o nowe.</string>
<string name="error_invitation_empty">To zaproszenie jest puste. Spróbuj otworzyć je ponownie.</string> <string name="error_invitation_empty">To zaproszenie jest puste. Spróbuj otworzyć je ponownie.</string>
<string name="error_missing_native_library">W tej kompilacji brakuje natywnej biblioteki VniDrop.</string> <string name="error_missing_native_library">W tej kompilacji brakuje natywnej biblioteki VniDrop.</string>
<string name="error_network">VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie.</string>
<string name="error_nfc">Nie udało się użyć tego tagu NFC. Spróbuj innego.</string> <string name="error_nfc">Nie udało się użyć tego tagu NFC. Spróbuj innego.</string>
<string name="error_permission">Nadawca nie zatwierdził tego transferu lub został on odrzucony.</string> <string name="error_permission">Nadawca nie zatwierdził tego transferu lub został on odrzucony.</string>
<string name="error_repository">VniDrop nie mógł zapisać danych transferu na tym urządzeniu.</string> <string name="error_repository">VniDrop nie mógł zapisać danych transferu na tym urządzeniu.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Wybierz co najmniej jeden element do udostępnienia.</string> <string name="error_share_empty">Wybierz co najmniej jeden element do udostępnienia.</string>
<string name="error_socket_bind">VniDrop nie mógł otworzyć gniazd sieciowych na tym urządzeniu.</string> <string name="error_socket_bind">VniDrop nie mógł otworzyć gniazd sieciowych na tym urządzeniu.</string>
<string name="error_starting_up">VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę.</string> <string name="error_starting_up">VniDrop jeszcze się uruchamia. Otwórz zaproszenie ponownie za chwilę.</string>
<string name="error_transfer">Nie udało się ukończyć transferu. Sprawdź połączenie i spróbuj ponownie.</string> <string name="error_storage_full">Brakuje miejsca na zapisanie tego transferu. Zwolnij miejsce i spróbuj ponownie.</string>
<string name="error_transfer">Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.</string>
<string name="field_receiver_name">Nazwa odbiorcy</string> <string name="field_receiver_name">Nazwa odbiorcy</string>
<string name="field_sender_name">Nazwa nadawcy</string> <string name="field_sender_name">Nazwa nadawcy</string>
<string name="field_transfer_name">Nazwa transferu</string> <string name="field_transfer_name">Nazwa transferu</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Partilhar diagnósticos</string> <string name="diagnostics_title">Partilhar diagnósticos</string>
<string name="error_camera">É necessário acesso à câmara para ler um código QR.</string> <string name="error_camera">É necessário acesso à câmara para ler um código QR.</string>
<string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string> <string name="error_device_info">Não foi possível carregar as informações do dispositivo.</string>
<string name="error_destination_exists">Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.</string>
<string name="error_filesystem">O VniDrop não conseguiu aceder aos ficheiros ou à pasta selecionados. Verifique as permissões e tente novamente.</string> <string name="error_filesystem">O VniDrop não conseguiu aceder aos ficheiros ou à pasta selecionados. Verifique as permissões e tente novamente.</string>
<string name="error_generic">Algo correu mal. Tente novamente.</string> <string name="error_generic">Algo correu mal. Tente novamente.</string>
<string name="error_initialization">O VniDrop não conseguiu concluir o arranque. Feche a app e tente novamente.</string> <string name="error_initialization">O VniDrop não conseguiu concluir o arranque. Feche a app e tente novamente.</string>
<string name="error_invalid_input">Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente.</string>
<string name="error_invalid_ticket">Não foi possível ler este convite. Peça um novo ao remetente.</string> <string name="error_invalid_ticket">Não foi possível ler este convite. Peça um novo ao remetente.</string>
<string name="error_invitation_empty">Esse convite está vazio. Tente abri-lo novamente.</string> <string name="error_invitation_empty">Esse convite está vazio. Tente abri-lo novamente.</string>
<string name="error_missing_native_library">A biblioteca nativa do VniDrop está em falta nesta compilação.</string> <string name="error_missing_native_library">A biblioteca nativa do VniDrop está em falta nesta compilação.</string>
<string name="error_network">O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente.</string>
<string name="error_nfc">Não foi possível utilizar esta etiqueta NFC. Tente outra.</string> <string name="error_nfc">Não foi possível utilizar esta etiqueta NFC. Tente outra.</string>
<string name="error_permission">O remetente não aprovou esta transferência, ou foi recusada.</string> <string name="error_permission">O remetente não aprovou esta transferência, ou foi recusada.</string>
<string name="error_repository">O VniDrop não conseguiu guardar os dados da transferência neste dispositivo.</string> <string name="error_repository">O VniDrop não conseguiu guardar os dados da transferência neste dispositivo.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Selecione pelo menos um item para partilhar.</string> <string name="error_share_empty">Selecione pelo menos um item para partilhar.</string>
<string name="error_socket_bind">O VniDrop não conseguiu abrir os seus sockets de rede neste dispositivo.</string> <string name="error_socket_bind">O VniDrop não conseguiu abrir os seus sockets de rede neste dispositivo.</string>
<string name="error_starting_up">O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos.</string> <string name="error_starting_up">O VniDrop ainda está a iniciar. Abra o convite novamente dentro de momentos.</string>
<string name="error_transfer">Não foi possível concluir a transferência. Verifique a sua ligão e tente novamente.</string> <string name="error_storage_full">Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente.</string>
<string name="error_transfer">Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.</string>
<string name="field_receiver_name">Nome do destinatário</string> <string name="field_receiver_name">Nome do destinatário</string>
<string name="field_sender_name">Nome do remetente</string> <string name="field_sender_name">Nome do remetente</string>
<string name="field_transfer_name">Nome da transferência</string> <string name="field_transfer_name">Nome da transferência</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Делиться диагностикой</string> <string name="diagnostics_title">Делиться диагностикой</string>
<string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string> <string name="error_camera">Для сканирования QR-кода требуется доступ к камере.</string>
<string name="error_device_info">Не удалось загрузить сведения об устройстве.</string> <string name="error_device_info">Не удалось загрузить сведения об устройстве.</string>
<string name="error_destination_exists">В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл.</string>
<string name="error_filesystem">VniDrop не удалось получить доступ к выбранным файлам или папке. Проверьте разрешения и повторите попытку.</string> <string name="error_filesystem">VniDrop не удалось получить доступ к выбранным файлам или папке. Проверьте разрешения и повторите попытку.</string>
<string name="error_generic">Что-то пошло не так. Повторите попытку.</string> <string name="error_generic">Что-то пошло не так. Повторите попытку.</string>
<string name="error_initialization">VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку.</string> <string name="error_initialization">VniDrop не удалось завершить запуск. Закройте приложение и повторите попытку.</string>
<string name="error_invalid_input">Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова.</string>
<string name="error_invalid_ticket">Не удалось прочитать это приглашение. Попросите отправителя прислать новое.</string> <string name="error_invalid_ticket">Не удалось прочитать это приглашение. Попросите отправителя прислать новое.</string>
<string name="error_invitation_empty">Это приглашение пустое. Попробуйте открыть его снова.</string> <string name="error_invitation_empty">Это приглашение пустое. Попробуйте открыть его снова.</string>
<string name="error_missing_native_library">В этой сборке отсутствует нативная библиотека VniDrop.</string> <string name="error_missing_native_library">В этой сборке отсутствует нативная библиотека VniDrop.</string>
<string name="error_network">VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку.</string>
<string name="error_nfc">Не удалось использовать эту NFC-метку. Попробуйте другую.</string> <string name="error_nfc">Не удалось использовать эту NFC-метку. Попробуйте другую.</string>
<string name="error_permission">Отправитель не одобрил эту передачу, или она была отклонена.</string> <string name="error_permission">Отправитель не одобрил эту передачу, или она была отклонена.</string>
<string name="error_repository">VniDrop не удалось сохранить данные передачи на этом устройстве.</string> <string name="error_repository">VniDrop не удалось сохранить данные передачи на этом устройстве.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Выберите хотя бы один объект для отправки.</string> <string name="error_share_empty">Выберите хотя бы один объект для отправки.</string>
<string name="error_socket_bind">VniDrop не удалось открыть сетевые сокеты на этом устройстве.</string> <string name="error_socket_bind">VniDrop не удалось открыть сетевые сокеты на этом устройстве.</string>
<string name="error_starting_up">VniDrop ещё запускается. Откройте приглашение снова через мгновение.</string> <string name="error_starting_up">VniDrop ещё запускается. Откройте приглашение снова через мгновение.</string>
<string name="error_transfer">Не удалось завершить передачу. Проверьте подключение и повторите попытку.</string> <string name="error_storage_full">Недостаточно места для сохранения этой передачи. Освободите место и повторите попытку.</string>
<string name="error_transfer">Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова.</string>
<string name="field_receiver_name">Имя получателя</string> <string name="field_receiver_name">Имя получателя</string>
<string name="field_sender_name">Имя отправителя</string> <string name="field_sender_name">Имя отправителя</string>
<string name="field_transfer_name">Название передачи</string> <string name="field_transfer_name">Название передачи</string>

View File

@@ -84,12 +84,15 @@
<string name="diagnostics_title">Share diagnostics</string> <string name="diagnostics_title">Share diagnostics</string>
<string name="error_camera">Camera access is required to scan a QR code.</string> <string name="error_camera">Camera access is required to scan a QR code.</string>
<string name="error_device_info">Could not load device information.</string> <string name="error_device_info">Could not load device information.</string>
<string name="error_destination_exists">A file with the same name already exists in the destination. Choose another folder or remove the existing file.</string>
<string name="error_filesystem">VniDrop could not access the selected files or folder. Check permissions and try again.</string> <string name="error_filesystem">VniDrop could not access the selected files or folder. Check permissions and try again.</string>
<string name="error_generic">Something went wrong. Try again.</string> <string name="error_generic">Something went wrong. Try again.</string>
<string name="error_initialization">VniDrop could not finish starting up. Close the app and try again.</string> <string name="error_initialization">VniDrop could not finish starting up. Close the app and try again.</string>
<string name="error_invalid_input">Some transfer information is invalid. Review your selection or ask the sender to share again.</string>
<string name="error_invalid_ticket">This invitation could not be read. Ask the sender for a new one.</string> <string name="error_invalid_ticket">This invitation could not be read. Ask the sender for a new one.</string>
<string name="error_invitation_empty">That invitation is empty. Try opening it again.</string> <string name="error_invitation_empty">That invitation is empty. Try opening it again.</string>
<string name="error_missing_native_library">The native VniDrop library is missing from this build.</string> <string name="error_missing_native_library">The native VniDrop library is missing from this build.</string>
<string name="error_network">VniDrop could not reach the sender. Check the connection on both devices and try again.</string>
<string name="error_nfc">This NFC tag could not be used. Try another tag.</string> <string name="error_nfc">This NFC tag could not be used. Try another tag.</string>
<string name="error_permission">The sender has not approved this transfer, or it was refused.</string> <string name="error_permission">The sender has not approved this transfer, or it was refused.</string>
<string name="error_repository">VniDrop could not save transfer data on this device.</string> <string name="error_repository">VniDrop could not save transfer data on this device.</string>
@@ -97,7 +100,8 @@
<string name="error_share_empty">Select at least one item to share.</string> <string name="error_share_empty">Select at least one item to share.</string>
<string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string> <string name="error_socket_bind">VniDrop could not open its network sockets on this device.</string>
<string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string> <string name="error_starting_up">VniDrop is still starting. Open the invitation again in a moment.</string>
<string name="error_transfer">The transfer could not be completed. Check your connection and try again.</string> <string name="error_storage_full">There is not enough storage space to save this transfer. Free up space and try again.</string>
<string name="error_transfer">The transfer data could not be processed. Ask the sender to share it again.</string>
<string name="field_receiver_name">Receiver name</string> <string name="field_receiver_name">Receiver name</string>
<string name="field_sender_name">Sender name</string> <string name="field_sender_name">Sender name</string>
<string name="field_transfer_name">Transfer name</string> <string name="field_transfer_name">Transfer name</string>

View File

@@ -16,6 +16,7 @@ import com.vnidrop.app.ui.feedback.UiMessage
import com.vnidrop.app.ui.feedback.UiMessageController import com.vnidrop.app.ui.feedback.UiMessageController
import com.vnidrop.app.ui.feedback.UiMessageTone import com.vnidrop.app.ui.feedback.UiMessageTone
import com.vnidrop.app.ui.feedback.UiText 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.isUserCancellation
import com.vnidrop.app.ui.feedback.toUiText import com.vnidrop.app.ui.feedback.toUiText
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -206,8 +207,12 @@ class ReceiveViewModel(
UiMessage( UiMessage(
text = uiText, text = uiText,
tone = UiMessageTone.Error, tone = UiMessageTone.Error,
actionLabel = UiText.Resource(Res.string.button_retry), actionLabel = if (error.canRetryWithoutChangingInput()) {
onAction = { receive() }, UiText.Resource(Res.string.button_retry)
} else {
null
},
onAction = if (error.canRetryWithoutChangingInput()) ({ receive() }) else null,
), ),
) )
}, },

View File

@@ -3,9 +3,11 @@ package com.vnidrop.app.ui.feedback
import uniffi.vnidrop.VnidropException import uniffi.vnidrop.VnidropException
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.error_device_info 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_filesystem
import vnidrop.shared.generated.resources.error_generic import vnidrop.shared.generated.resources.error_generic
import vnidrop.shared.generated.resources.error_initialization 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_invalid_ticket
import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.error_invitation_empty
import vnidrop.shared.generated.resources.error_missing_native_library 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_socket_bind
import vnidrop.shared.generated.resources.error_camera import vnidrop.shared.generated.resources.error_camera
import vnidrop.shared.generated.resources.error_nfc 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_share_empty
import vnidrop.shared.generated.resources.error_starting_up import vnidrop.shared.generated.resources.error_starting_up
import vnidrop.shared.generated.resources.error_storage_full
import vnidrop.shared.generated.resources.error_transfer 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.Ticket -> UiText.Resource(Res.string.error_invalid_ticket)
is VnidropException.Permission -> UiText.Resource(Res.string.error_permission) is VnidropException.Permission -> UiText.Resource(Res.string.error_permission)
is VnidropException.Filesystem -> UiText.Resource(Res.string.error_filesystem) 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.Transfer -> transferUiText(reason)
is VnidropException.Repository -> UiText.Resource(Res.string.error_repository) 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.Initialization -> initializationUiText(reason)
is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic) is VnidropException.Internal -> reasonHints(reason) ?: UiText.Resource(Res.string.error_generic)
else -> reasonHints(technicalDetail()) ?: 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. */ /** User intentionally backed out of a flow — do not treat as a failure snackbar. */
fun Throwable.isUserCancellation(): Boolean { fun Throwable.isUserCancellation(): Boolean {
if (this is VnidropException.Cancelled) return true
val haystack = technicalDetail().lowercase() val haystack = technicalDetail().lowercase()
if (haystack.isBlank()) return false if (haystack.isBlank()) return false
return haystack.contains("cancelled") || return haystack.contains("cancelled") ||
@@ -46,15 +57,26 @@ fun Throwable.isUserCancellation(): Boolean {
haystack.contains("user canceled") 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]. */ /** Prefer [VnidropException.reason] when present; else [Throwable.message]. */
fun Throwable.technicalDetail(): String = fun Throwable.technicalDetail(): String =
when (this) { when (this) {
is VnidropException.Initialization -> reason is VnidropException.Initialization -> reason
is VnidropException.Ticket -> reason is VnidropException.Ticket -> reason
is VnidropException.Filesystem -> 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.Transfer -> reason
is VnidropException.Permission -> reason is VnidropException.Permission -> reason
is VnidropException.Repository -> reason is VnidropException.Repository -> reason
is VnidropException.Cancelled -> reason
is VnidropException.InvalidInput -> reason
is VnidropException.Internal -> reason is VnidropException.Internal -> reason
else -> message.orEmpty() else -> message.orEmpty()
} }

View File

@@ -50,9 +50,11 @@ import kotlin.test.assertContentEquals
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
import uniffi.vnidrop.VnidropException
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.button_show_in_files import vnidrop.shared.generated.resources.button_show_in_files
import vnidrop.shared.generated.resources.error_permission import vnidrop.shared.generated.resources.error_permission
import vnidrop.shared.generated.resources.error_destination_exists
import vnidrop.shared.generated.resources.receive_open_files_failed import vnidrop.shared.generated.resources.receive_open_files_failed
@OptIn(ExperimentalCoroutinesApi::class) @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 @Test
fun receiveViewModelCancelUsesActiveTransferId() = runTest { fun receiveViewModelCancelUsesActiveTransferId() = runTest {
Dispatchers.setMain(StandardTestDispatcher(testScheduler)) Dispatchers.setMain(StandardTestDispatcher(testScheduler))

View File

@@ -7,8 +7,10 @@ import kotlin.test.assertTrue
import uniffi.vnidrop.VnidropException import uniffi.vnidrop.VnidropException
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.error_filesystem 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_generic
import vnidrop.shared.generated.resources.error_initialization 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_invalid_ticket
import vnidrop.shared.generated.resources.error_invitation_empty import vnidrop.shared.generated.resources.error_invitation_empty
import vnidrop.shared.generated.resources.error_missing_native_library 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_socket_bind
import vnidrop.shared.generated.resources.error_camera import vnidrop.shared.generated.resources.error_camera
import vnidrop.shared.generated.resources.error_nfc 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_share_empty
import vnidrop.shared.generated.resources.error_starting_up import vnidrop.shared.generated.resources.error_starting_up
import vnidrop.shared.generated.resources.error_storage_full
import vnidrop.shared.generated.resources.error_transfer import vnidrop.shared.generated.resources.error_transfer
class UserFacingErrorTest { class UserFacingErrorTest {
@@ -37,6 +41,26 @@ class UserFacingErrorTest {
UiText.Resource(Res.string.error_filesystem), UiText.Resource(Res.string.error_filesystem),
VnidropException.Filesystem("permission denied opening path").toUiText(), 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( assertEquals(
UiText.Resource(Res.string.error_transfer), UiText.Resource(Res.string.error_transfer),
VnidropException.Transfer("connection reset").toUiText(), VnidropException.Transfer("connection reset").toUiText(),
@@ -119,6 +143,15 @@ class UserFacingErrorTest {
assertTrue(IllegalStateException("QR scanning was cancelled").isUserCancellation()) assertTrue(IllegalStateException("QR scanning was cancelled").isUserCancellation())
assertTrue(IllegalStateException("NFC writing was cancelled").isUserCancellation()) assertTrue(IllegalStateException("NFC writing was cancelled").isUserCancellation())
assertTrue(VnidropException.Transfer("transfer cancelled by user").isUserCancellation()) assertTrue(VnidropException.Transfer("transfer cancelled by user").isUserCancellation())
assertTrue(VnidropException.Cancelled("cancel requested").isUserCancellation())
assertFalse(IllegalStateException("sender refused").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())
}
} }