mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-07 11:19:58 +02:00
Two causes. The connect step had no timeout, so an unreachable device was retried indefinitely instead of falling through to hold-for-later; it now gives up after 15s and holds the offer as designed. The picker also waited on the whole exchange, which includes a person on the other device deciding — up to two minutes. It now closes on tap and reports the outcome as a message, and a decline or an unanswered offer is shown as information rather than an error, since the offer did arrive.
239 lines
8.6 KiB
Swift
239 lines
8.6 KiB
Swift
import Foundation
|
|
import VnidropCore
|
|
|
|
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
|
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
|
/// How an offered transfer ended without being accepted.
|
|
enum OfferRefusal {
|
|
case declined
|
|
case noAnswer
|
|
}
|
|
|
|
extension Error {
|
|
func toUiText() -> UiText {
|
|
if let invitation = self as? InvitationError {
|
|
return invitation.uiText
|
|
}
|
|
if let vni = self as? VnidropError {
|
|
switch vni {
|
|
case .Ticket:
|
|
return .resource(L10n.Error.invalidTicket)
|
|
case .Permission:
|
|
return .resource(L10n.Error.permission)
|
|
case .Filesystem:
|
|
return .resource(L10n.Error.filesystem)
|
|
case .FilesystemPermission:
|
|
return .resource(L10n.Error.filesystem)
|
|
case .DestinationExists:
|
|
return .resource(L10n.Error.destinationExists)
|
|
case .StorageFull:
|
|
return .resource(L10n.Error.storageFull)
|
|
case .Network:
|
|
return .resource(L10n.Error.network)
|
|
case .Transfer(let reason):
|
|
return transferUiText(reason)
|
|
case .Repository:
|
|
return .resource(L10n.Error.repository)
|
|
case .Cancelled:
|
|
return .resource(L10n.Error.generic)
|
|
case .InvalidInput:
|
|
return .resource(L10n.Error.invalidInput)
|
|
case .Initialization(let reason):
|
|
return initializationUiText(reason)
|
|
case .Internal(let reason):
|
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
|
}
|
|
}
|
|
return reasonHints(technicalDetail) ?? .resource(L10n.Error.generic)
|
|
}
|
|
|
|
/// True when the user intentionally backed out of a flow.
|
|
var isUserCancellation: Bool {
|
|
if let invitation = self as? InvitationError, case .cancelled = invitation { return true }
|
|
if let vni = self as? VnidropError, case .Cancelled = vni { return true }
|
|
let haystack = technicalDetail.lowercased()
|
|
if haystack.isEmpty {
|
|
// URLError / CocoaError cancellation without a message.
|
|
if let urlError = self as? URLError, urlError.code == .cancelled { return true }
|
|
return (self as NSError).code == NSUserCancelledError
|
|
}
|
|
return haystack.contains("cancelled")
|
|
|| haystack.contains("canceled")
|
|
|| haystack.contains("user cancelled")
|
|
|| haystack.contains("user canceled")
|
|
}
|
|
|
|
/// The other device answered, and the answer was no.
|
|
///
|
|
/// Not a failure of this device: the offer was delivered and a person
|
|
/// declined it, so it is reported as information rather than an error.
|
|
var offerRefusal: OfferRefusal? {
|
|
let haystack = technicalDetail.lowercased()
|
|
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
|
|
return .declined
|
|
}
|
|
if haystack.contains("no-response") { return .noAnswer }
|
|
return nil
|
|
}
|
|
|
|
/// Prefers a `VnidropError` reason; else the localized description.
|
|
var technicalDetail: String {
|
|
if let vni = self as? VnidropError {
|
|
switch vni {
|
|
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
|
|
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
|
|
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
|
|
.InvalidInput(let r), .Internal(let r):
|
|
return r
|
|
}
|
|
}
|
|
return (self as? LocalizedError)?.errorDescription ?? (self as NSError).localizedDescription
|
|
}
|
|
|
|
var canRetryWithoutChangingInput: Bool {
|
|
guard let vni = self as? VnidropError else { return true }
|
|
switch vni {
|
|
case .FilesystemPermission, .DestinationExists, .InvalidInput: return false
|
|
default: return true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Maps each semantic `InvitationError` case to a localized user-facing message.
|
|
/// This is the sole `InvitationError` → `L10n` boundary: no substring guessing,
|
|
/// except for `.raw`, whose dynamic payload still falls through `reasonHints`.
|
|
extension InvitationError {
|
|
var uiText: UiText {
|
|
switch self {
|
|
case .empty:
|
|
return .resource(L10n.Error.invitationEmpty)
|
|
case .tooLarge, .unsupportedOperation, .noWindowAvailable,
|
|
.viewControllerUnavailable, .qrUnavailable, .bugReportingUnavailable, .cancelled:
|
|
return .resource(L10n.Error.generic)
|
|
case .invalidEncoding, .invalidInvitationURL:
|
|
return .resource(L10n.Error.invalidTicket)
|
|
case .shareEmpty:
|
|
return .resource(L10n.Error.shareEmpty)
|
|
case .coreNotInitialized:
|
|
return .resource(L10n.Error.startingUp)
|
|
case .filesystemUnavailable:
|
|
return .resource(L10n.Error.filesystem)
|
|
case .nfcUnavailable, .nfcFailed:
|
|
return .resource(L10n.Error.nfc)
|
|
case .cameraUnavailable:
|
|
return .resource(L10n.Error.camera)
|
|
case .selectionFailed:
|
|
return .resource(L10n.Error.selectionFailed)
|
|
case .deleteRecordsFailed:
|
|
return .resource(L10n.Error.repository)
|
|
case .raw(let reason):
|
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Maps a receiver delivery/refusal reason code to a user-facing message, never
|
|
/// surfacing the raw core code (e.g. `destination_exists`). Unknown codes fall back
|
|
/// to the substring hints, then a generic message.
|
|
func receiverReasonUiText(_ reason: String) -> UiText {
|
|
switch reason {
|
|
case "destination_exists":
|
|
return .resource(L10n.Error.destinationExists)
|
|
case "filesystem", "filesystem_permission_denied":
|
|
return .resource(L10n.Error.filesystem)
|
|
case "permission_denied", "approval-required", "approval-expired",
|
|
"unknown-transfer", "missing-endpoint-id", "invalid-receipt":
|
|
return .resource(L10n.Error.permission)
|
|
case "storage_full":
|
|
return .resource(L10n.Error.storageFull)
|
|
case "network":
|
|
return .resource(L10n.Error.network)
|
|
case "invalid_ticket":
|
|
return .resource(L10n.Error.invalidTicket)
|
|
case "transfer":
|
|
return .resource(L10n.Error.transfer)
|
|
case "repository", "repository-error":
|
|
return .resource(L10n.Error.repository)
|
|
case "invalid_input":
|
|
return .resource(L10n.Error.invalidInput)
|
|
case "initialization":
|
|
return .resource(L10n.Error.initialization)
|
|
case "cancelled", "internal":
|
|
return .resource(L10n.Error.generic)
|
|
default:
|
|
return reasonHints(reason) ?? .resource(L10n.Error.generic)
|
|
}
|
|
}
|
|
|
|
private func transferUiText(_ reason: String) -> UiText {
|
|
let detail = reason.lowercased()
|
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("not approved") {
|
|
return .resource(L10n.Error.permission)
|
|
}
|
|
return .resource(L10n.Error.transfer)
|
|
}
|
|
|
|
private func initializationUiText(_ reason: String) -> UiText {
|
|
let detail = reason.lowercased()
|
|
if detail.contains("native") && detail.contains("library") {
|
|
return .resource(L10n.Error.missingNativeLibrary)
|
|
}
|
|
if detail.contains("socket") || detail.contains("bind") {
|
|
return .resource(L10n.Error.socketBind)
|
|
}
|
|
return .resource(L10n.Error.initialization)
|
|
}
|
|
|
|
private func reasonHints(_ detailRaw: String) -> UiText? {
|
|
let detail = detailRaw.lowercased()
|
|
if detail.isEmpty { return nil }
|
|
|
|
if detail.contains("still starting") || detail.contains("starting up") {
|
|
return .resource(L10n.Error.startingUp)
|
|
}
|
|
if detail.contains("empty") && (detail.contains("invitation") || detail.contains("ticket") || detail.contains("qr")) {
|
|
return .resource(L10n.Error.invitationEmpty)
|
|
}
|
|
if detail.contains("select at least one") || detail.contains("no files found") {
|
|
return .resource(L10n.Error.shareEmpty)
|
|
}
|
|
if detail.contains("camera") {
|
|
return .resource(L10n.Error.camera)
|
|
}
|
|
if detail.contains("nfc") || detail.contains("ndef")
|
|
|| (detail.contains("read-only") && detail.contains("tag"))
|
|
|| detail.contains("tag is too small") || detail.contains("no nfc tag") {
|
|
return .resource(L10n.Error.nfc)
|
|
}
|
|
if detail.contains("native") && detail.contains("library") {
|
|
return .resource(L10n.Error.missingNativeLibrary)
|
|
}
|
|
if detail.contains("socket") || detail.contains("bind") {
|
|
return .resource(L10n.Error.socketBind)
|
|
}
|
|
if detail.contains("device information") || detail.contains("device info") {
|
|
return .resource(L10n.Error.deviceInfo)
|
|
}
|
|
if detail.contains("refused") || detail.contains("denied") || detail.contains("permission")
|
|
|| detail.contains("not approved") || detail.contains("waiting for approval") {
|
|
return .resource(L10n.Error.permission)
|
|
}
|
|
if detail.contains("invalid ticket") || detail.contains("ticket error")
|
|
|| detail.contains("could not be read") || detail.contains("malformed")
|
|
|| detail.contains("invitation could not be opened") {
|
|
return .resource(L10n.Error.invalidTicket)
|
|
}
|
|
if detail.contains("selected")
|
|
&& (detail.contains("file") || detail.contains("folder") || detail.contains("document") || detail.contains("open")) {
|
|
return .resource(L10n.Error.selectionFailed)
|
|
}
|
|
if detail.contains("could not open the selected") || detail.contains("could not open selected") {
|
|
return .resource(L10n.Error.selectionFailed)
|
|
}
|
|
if detail.contains("document picker") || detail.contains("folder picker") || detail.contains("file descriptor")
|
|
|| detail.contains("view controller") {
|
|
return .resource(L10n.Error.selectionFailed)
|
|
}
|
|
return nil
|
|
}
|