mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
fix(core): preserve typed transfer failures
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5109,6 +5109,66 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"error_destination_exists": {
|
||||||
|
"comment": "Error: a received file would overwrite an existing destination file.",
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "A file with the same name already exists in the destination. Choose another folder or remove the existing file."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Ya existe un archivo con el mismo nombre en el destino. Elija otra carpeta o elimine el archivo existente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Un fichier portant le même nom existe déjà dans la destination. Choisissez un autre dossier ou supprimez le fichier existant."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Nella destinazione esiste già un file con lo stesso nome. Scelga un’altra cartella o rimuova il file esistente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_device_info": {
|
"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 à l’expéditeur de partager à nouveau."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_invalid_ticket": {
|
"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 n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_nfc": {
|
"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 n’a pas pu être terminé. Vérifiez votre connexion et réessayez."
|
"value": "L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it": {
|
"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 ligação e tente novamente."
|
"value": "Não existe espaço de armazenamento suficiente para guardar esta transferência. Liberte espaço e tente novamente."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ru": {
|
"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 n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "needs_review",
|
||||||
|
"value": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
54
crates/vnidrop/src/tests/error.rs
Normal file
54
crates/vnidrop/src/tests/error.rs
Normal 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 { .. }));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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\"")
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 un’altra cartella o rimuova il file esistente.",
|
||||||
|
"de": "Am Ziel ist bereits eine Datei mit demselben Namen vorhanden. Wählen Sie einen anderen Ordner oder entfernen Sie die vorhandene Datei.",
|
||||||
|
"pt": "Já existe um ficheiro com o mesmo nome no destino. Escolha outra pasta ou remova o ficheiro existente.",
|
||||||
|
"pl": "W miejscu docelowym istnieje już plik o tej samej nazwie. Wybierz inny folder lub usuń istniejący plik.",
|
||||||
|
"nl": "Er staat al een bestand met dezelfde naam in de doelmap. Kies een andere map of verwijder het bestaande bestand.",
|
||||||
|
"ru": "В папке назначения уже есть файл с таким именем. Выберите другую папку или удалите существующий файл."
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_filesystem": {
|
"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 à l’expéditeur de partager à nouveau.",
|
||||||
|
"es": "Parte de la información de la transferencia no es válida. Revise la selección o pida al remitente que vuelva a compartirla.",
|
||||||
|
"it": "Alcune informazioni del trasferimento non sono valide. Controlli la selezione o chieda al mittente di condividere di nuovo.",
|
||||||
|
"de": "Einige Übertragungsinformationen sind ungültig. Prüfen Sie Ihre Auswahl oder bitten Sie den Absender, erneut zu teilen.",
|
||||||
|
"pt": "Algumas informações da transferência são inválidas. Reveja a seleção ou peça ao remetente para partilhar novamente.",
|
||||||
|
"pl": "Niektóre informacje o transferze są nieprawidłowe. Sprawdź wybór lub poproś nadawcę o ponowne udostępnienie.",
|
||||||
|
"nl": "Sommige overdrachtsgegevens zijn ongeldig. Controleer uw selectie of vraag de afzender opnieuw te delen.",
|
||||||
|
"ru": "Некоторые данные передачи недействительны. Проверьте выбор или попросите отправителя поделиться снова."
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_invalid_ticket": {
|
"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 n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez.",
|
||||||
|
"es": "VniDrop no pudo contactar con el remitente. Compruebe la conexión en ambos dispositivos e inténtelo de nuevo.",
|
||||||
|
"it": "VniDrop non è riuscito a raggiungere il mittente. Controlli la connessione su entrambi i dispositivi e riprovi.",
|
||||||
|
"de": "VniDrop konnte den Absender nicht erreichen. Prüfen Sie die Verbindung auf beiden Geräten und versuchen Sie es erneut.",
|
||||||
|
"pt": "O VniDrop não conseguiu contactar o remetente. Verifique a ligação nos dois dispositivos e tente novamente.",
|
||||||
|
"pl": "VniDrop nie mógł połączyć się z nadawcą. Sprawdź połączenie na obu urządzeniach i spróbuj ponownie.",
|
||||||
|
"nl": "VniDrop kon de afzender niet bereiken. Controleer de verbinding op beide apparaten en probeer het opnieuw.",
|
||||||
|
"ru": "VniDrop не удалось связаться с отправителем. Проверьте подключение на обоих устройствах и повторите попытку."
|
||||||
|
}
|
||||||
|
},
|
||||||
"error_nfc": {
|
"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 n’a pas pu être terminé. Vérifiez votre connexion et réessayez.",
|
"fr": "L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace 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 ligaçã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 n’ont pas pu être traitées. Demandez à l’expéditeur de les partager à nouveau.",
|
||||||
|
"es": "No se pudieron procesar los datos de la transferencia. Pida al remitente que vuelva a compartirlos.",
|
||||||
|
"it": "Non è stato possibile elaborare i dati del trasferimento. Chieda al mittente di condividerli di nuovo.",
|
||||||
|
"de": "Die Übertragungsdaten konnten nicht verarbeitet werden. Bitten Sie den Absender, sie erneut zu teilen.",
|
||||||
|
"pt": "Não foi possível processar os dados da transferência. Peça ao remetente para os partilhar novamente.",
|
||||||
|
"pl": "Nie udało się przetworzyć danych transferu. Poproś nadawcę o ponowne udostępnienie.",
|
||||||
|
"nl": "De overdrachtsgegevens konden niet worden verwerkt. Vraag de afzender ze opnieuw te delen.",
|
||||||
|
"ru": "Не удалось обработать данные передачи. Попросите отправителя поделиться ими снова."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"field_receiver_name": {
|
"field_receiver_name": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -84,12 +84,15 @@
|
|||||||
<string name="diagnostics_title">Partager les diagnostics</string>
|
<string name="diagnostics_title">Partager les diagnostics</string>
|
||||||
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
<string name="error_camera">L’accès à la caméra est nécessaire pour scanner un QR code.</string>
|
||||||
<string name="error_device_info">Impossible de charger les informations de l’appareil.</string>
|
<string name="error_device_info">Impossible de charger les informations de l’appareil.</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 n’a 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 n’a 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 n’a pas pu terminer son démarrage. Fermez l’app et réessayez.</string>
|
<string name="error_initialization">VniDrop n’a pas pu terminer son démarrage. Fermez l’app et réessayez.</string>
|
||||||
|
<string name="error_invalid_input">Certaines informations du transfert ne sont pas valides. Vérifiez votre sélection ou demandez à l’expéditeur de partager à nouveau.</string>
|
||||||
<string name="error_invalid_ticket">Cette invitation n’a pas pu être lue. Demandez-en une nouvelle à l’expéditeur.</string>
|
<string name="error_invalid_ticket">Cette invitation n’a pas pu être lue. Demandez-en une nouvelle à l’expéditeur.</string>
|
||||||
<string name="error_invitation_empty">Cette invitation est vide. Essayez de l’ouvrir à nouveau.</string>
|
<string name="error_invitation_empty">Cette invitation est vide. Essayez de l’ouvrir à 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 n’a pas pu joindre l’expéditeur. Vérifiez la connexion sur les deux appareils et réessayez.</string>
|
||||||
<string name="error_nfc">Ce tag NFC n’a pas pu être utilisé. Essayez-en un autre.</string>
|
<string name="error_nfc">Ce tag NFC n’a pas pu être utilisé. Essayez-en un autre.</string>
|
||||||
<string name="error_permission">L’expéditeur n’a pas approuvé ce transfert, ou il a été refusé.</string>
|
<string name="error_permission">L’expéditeur n’a pas approuvé ce transfert, ou il a été refusé.</string>
|
||||||
<string name="error_repository">VniDrop n’a pas pu enregistrer les données de transfert sur cet appareil.</string>
|
<string name="error_repository">VniDrop n’a 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 n’a pas pu ouvrir ses sockets réseau sur cet appareil.</string>
|
<string name="error_socket_bind">VniDrop n’a pas pu ouvrir ses sockets réseau sur cet appareil.</string>
|
||||||
<string name="error_starting_up">VniDrop démarre encore. Rouvrez l’invitation dans un instant.</string>
|
<string name="error_starting_up">VniDrop démarre encore. Rouvrez l’invitation dans un instant.</string>
|
||||||
<string name="error_transfer">Le transfert n’a pas pu être terminé. Vérifiez votre connexion et réessayez.</string>
|
<string name="error_storage_full">L’espace de stockage est insuffisant pour enregistrer ce transfert. Libérez de l’espace et réessayez.</string>
|
||||||
|
<string name="error_transfer">Les données du transfert n’ont pas pu être traitées. Demandez à l’expé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 l’expéditeur</string>
|
<string name="field_sender_name">Nom de l’expéditeur</string>
|
||||||
<string name="field_transfer_name">Nom du transfert</string>
|
<string name="field_transfer_name">Nom du transfert</string>
|
||||||
|
|||||||
@@ -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 l’accesso alla fotocamera.</string>
|
<string name="error_camera">Per scansionare un codice QR è necessario l’accesso 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 un’altra 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 l’avvio. Chiuda l’app e riprovi.</string>
|
<string name="error_initialization">VniDrop non ha potuto completare l’avvio. Chiuda l’app 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 l’invito tra un momento.</string>
|
<string name="error_starting_up">VniDrop è ancora in fase di avvio. Riapra l’invito 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ligaçã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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user