db65db6232
Android UI Tests / ui-tests (push) Has been cancelled
Localize all user-facing strings across iOS (SwiftUI), shared Kotlin, and Android Compose into en/es/fr/de/pt/it/ja/ko/nl/zh: - iOS String Catalogs: main + widget Localizable.xcstrings, InfoPlist.xcstrings (permissions), plural variations, ~200 new keys translated - Shared Kotlin ClientStrings table + Android composeResources/values-* (884 keys ×10), routed Api/ViewModel/util error & UI strings through localization - Backend-localized lookups/suggestions consumed via display names - Widget extension catalog; theme names, home-profile fallbacks, validation, network errors, accessibility labels all localized Add re-runnable verification gates: - scripts/i18n_audit.py — enumerate every literal, partition to GAP=0 - scripts/i18n_coverage.py — all 10 locales translated, format-specifier parity Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
279 lines
17 KiB
Swift
279 lines
17 KiB
Swift
import Foundation
|
|
|
|
/// Utility for parsing and cleaning error messages from API responses and network errors
|
|
enum ErrorMessageParser {
|
|
|
|
// MARK: - API Error Code Mappings
|
|
|
|
/// Maps backend error codes to user-friendly messages
|
|
private static let errorCodeMappings: [String: String] = [
|
|
// Authentication errors
|
|
"error.invalid_credentials": String(localized: "Invalid username or password. Please try again."),
|
|
"error.invalid_token": String(localized: "Your session has expired. Please log in again."),
|
|
"error.not_authenticated": String(localized: "Please log in to continue."),
|
|
"error.account_inactive": String(localized: "Your account is inactive. Please contact support."),
|
|
"error.username_taken": String(localized: "This username is already taken. Please choose another."),
|
|
"error.email_taken": String(localized: "This email is already registered. Try logging in instead."),
|
|
"error.email_already_taken": String(localized: "This email is already in use."),
|
|
"error.registration_failed": String(localized: "Registration failed. Please try again."),
|
|
"error.failed_to_get_user": String(localized: "Unable to load your profile. Please try again."),
|
|
"error.failed_to_update_profile": String(localized: "Unable to update your profile. Please try again."),
|
|
|
|
// Email verification errors
|
|
"error.invalid_verification_code": String(localized: "Invalid verification code. Please check and try again."),
|
|
"error.verification_code_expired": String(localized: "Your verification code has expired. Please request a new one."),
|
|
"error.email_already_verified": String(localized: "Your email is already verified."),
|
|
"error.verification_failed": String(localized: "Verification failed. Please try again."),
|
|
"error.failed_to_resend_verification": String(localized: "Unable to send verification code. Please try again."),
|
|
|
|
// Password reset errors
|
|
"error.rate_limit_exceeded": String(localized: "Too many attempts. Please wait a few minutes and try again."),
|
|
"error.too_many_requests": String(localized: "Too many requests. Please try again later."),
|
|
"error.too_many_attempts": String(localized: "Too many attempts. Please request a new code."),
|
|
"error.invalid_reset_token": String(localized: "This reset link has expired. Please request a new one."),
|
|
"error.password_reset_failed": String(localized: "Password reset failed. Please try again."),
|
|
|
|
// Social sign-in errors
|
|
"error.apple_signin_not_configured": String(localized: "Apple Sign In is not available. Please use email login."),
|
|
"error.apple_signin_failed": String(localized: "Apple Sign In failed. Please try again."),
|
|
"error.invalid_apple_token": String(localized: "Apple Sign In failed. Please try again."),
|
|
"error.google_signin_not_configured": String(localized: "Google Sign In is not available. Please use email login."),
|
|
"error.google_signin_failed": String(localized: "Google Sign In failed. Please try again."),
|
|
"error.invalid_google_token": String(localized: "Google Sign In failed. Please try again."),
|
|
|
|
// Resource not found errors
|
|
"error.task_not_found": String(localized: "Task not found. It may have been deleted."),
|
|
"error.residence_not_found": String(localized: "Property not found. It may have been deleted."),
|
|
"error.contractor_not_found": String(localized: "Contractor not found. It may have been deleted."),
|
|
"error.document_not_found": String(localized: "Document not found. It may have been deleted."),
|
|
"error.completion_not_found": String(localized: "Task completion not found."),
|
|
"error.user_not_found": String(localized: "User not found."),
|
|
"error.notification_not_found": String(localized: "Notification not found."),
|
|
"error.template_not_found": String(localized: "Task template not found."),
|
|
"error.upgrade_trigger_not_found": String(localized: "Feature not available."),
|
|
|
|
// Access denied errors
|
|
"error.task_access_denied": String(localized: "You don't have permission to view this task."),
|
|
"error.residence_access_denied": String(localized: "You don't have permission to view this property."),
|
|
"error.contractor_access_denied": String(localized: "You don't have permission to view this contractor."),
|
|
"error.document_access_denied": String(localized: "You don't have permission to view this document."),
|
|
"error.not_residence_owner": String(localized: "Only the property owner can do this."),
|
|
"error.cannot_remove_owner": String(localized: "The property owner cannot be removed."),
|
|
"error.access_denied": String(localized: "You don't have permission for this action."),
|
|
|
|
// Sharing errors
|
|
"error.share_code_invalid": String(localized: "Invalid share code. Please check and try again."),
|
|
"error.share_code_expired": String(localized: "This share code has expired. Please request a new one."),
|
|
"error.user_already_member": String(localized: "This user is already a member of this property."),
|
|
|
|
// Subscription/limit errors
|
|
"error.properties_limit_reached": String(localized: "You've reached your property limit. Upgrade to add more."),
|
|
"error.properties_limit_exceeded": String(localized: "You've reached your property limit. Upgrade to add more."),
|
|
"error.tasks_limit_exceeded": String(localized: "You've reached your task limit. Upgrade to add more."),
|
|
"error.contractors_limit_exceeded": String(localized: "You've reached your contractor limit. Upgrade to add more."),
|
|
"error.documents_limit_exceeded": String(localized: "You've reached your document limit. Upgrade to add more."),
|
|
|
|
// Task state errors
|
|
"error.task_already_cancelled": String(localized: "This task has already been cancelled."),
|
|
"error.task_already_archived": String(localized: "This task has already been archived."),
|
|
|
|
// Form/upload errors
|
|
"error.failed_to_parse_form": String(localized: "Unable to process the form. Please try again."),
|
|
"error.task_id_required": String(localized: "Task ID is required."),
|
|
"error.residence_id_required": String(localized: "Property ID is required."),
|
|
"error.title_required": String(localized: "Title is required."),
|
|
"error.failed_to_upload_image": String(localized: "Unable to upload image. Please try again."),
|
|
"error.failed_to_upload_file": String(localized: "Unable to upload file. Please try again."),
|
|
"error.no_file_provided": String(localized: "Please select a file to upload."),
|
|
|
|
// Invalid ID errors
|
|
"error.invalid_task_id": String(localized: "Invalid task."),
|
|
"error.invalid_task_id_value": String(localized: "Invalid task."),
|
|
"error.invalid_residence_id": String(localized: "Invalid property."),
|
|
"error.invalid_residence_id_value": String(localized: "Invalid property."),
|
|
"error.invalid_contractor_id": String(localized: "Invalid contractor."),
|
|
"error.invalid_document_id": String(localized: "Invalid document."),
|
|
"error.invalid_completion_id": String(localized: "Invalid task completion."),
|
|
"error.invalid_user_id": String(localized: "Invalid user."),
|
|
"error.invalid_notification_id": String(localized: "Invalid notification."),
|
|
"error.invalid_device_id": String(localized: "Invalid device."),
|
|
"error.invalid_platform": String(localized: "Invalid platform."),
|
|
"error.invalid_id": String(localized: "Invalid ID."),
|
|
|
|
// Data fetch errors
|
|
"error.failed_to_fetch_residence_types": String(localized: "Unable to load property types. Please try again."),
|
|
"error.failed_to_fetch_task_categories": String(localized: "Unable to load task categories. Please try again."),
|
|
"error.failed_to_fetch_task_priorities": String(localized: "Unable to load task priorities. Please try again."),
|
|
"error.failed_to_fetch_task_frequencies": String(localized: "Unable to load task frequencies. Please try again."),
|
|
"error.failed_to_fetch_task_statuses": String(localized: "Unable to load task statuses. Please try again."),
|
|
"error.failed_to_fetch_contractor_specialties": String(localized: "Unable to load contractor specialties. Please try again."),
|
|
"error.failed_to_fetch_templates": String(localized: "Unable to load task templates. Please try again."),
|
|
"error.failed_to_search_templates": String(localized: "Unable to search templates. Please try again."),
|
|
|
|
// Subscription purchase errors
|
|
"error.receipt_data_required": String(localized: "Purchase verification failed. Please try again."),
|
|
"error.purchase_token_required": String(localized: "Purchase verification failed. Please try again."),
|
|
|
|
// Media errors
|
|
"error.file_not_found": String(localized: "File not found."),
|
|
"error.image_not_found": String(localized: "Image not found."),
|
|
|
|
// Generic errors
|
|
"error.invalid_request": String(localized: "Invalid request. Please try again."),
|
|
"error.invalid_request_body": String(localized: "Invalid request. Please check your input."),
|
|
"error.internal": String(localized: "Something went wrong. Please try again."),
|
|
"error.query_required": String(localized: "Search query is required."),
|
|
"error.query_too_short": String(localized: "Search query is too short.")
|
|
]
|
|
|
|
// MARK: - Network Error Patterns
|
|
|
|
/// Network/connection error patterns to detect
|
|
private static let networkErrorPatterns: [(pattern: String, message: String)] = [
|
|
("Could not connect to the server", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("NSURLErrorDomain", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("The Internet connection appears to be offline", String(localized: "No internet connection. Please check your network.")), // i18n-ignore: lhs=error-detection substring
|
|
("A server with the specified hostname could not be found", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("The request timed out", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("The network connection was lost", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("An SSL error has occurred", String(localized: "Secure connection failed. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("CFNetwork", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("kCFStreamError", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1004", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1009", String(localized: "No internet connection. Please check your network.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1001", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1003", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1005", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("Code=-1200", String(localized: "Secure connection failed. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("UnresolvedAddressException", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("ConnectException", String(localized: "Unable to connect. Please check your internet connection.")), // i18n-ignore: lhs=error-detection substring
|
|
("SocketTimeoutException", String(localized: "Request timed out. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("Connection refused", String(localized: "Unable to connect. The server may be temporarily unavailable.")), // i18n-ignore: lhs=error-detection substring
|
|
("Connection reset", String(localized: "Connection lost. Please try again.")), // i18n-ignore: lhs=error-detection substring
|
|
("Too many requests", String(localized: "Too many requests. Please try again later.")) // i18n-ignore: lhs=error-detection substring
|
|
]
|
|
|
|
// MARK: - Technical Error Indicators
|
|
|
|
/// Indicators that a message is technical/developer-facing
|
|
// i18n-ignore-begin: error-detection substrings matched against incoming exception text (non-UI)
|
|
private static let technicalIndicators = [
|
|
"Exception",
|
|
"Error Domain=",
|
|
"UserInfo=",
|
|
"at com.",
|
|
"at org.",
|
|
"at java.",
|
|
"at kotlin.",
|
|
"at io.",
|
|
"Caused by:",
|
|
"Stack trace:",
|
|
".kt:",
|
|
".java:",
|
|
".swift:",
|
|
"0x",
|
|
"Code=",
|
|
"interface:",
|
|
"LocalDataTask",
|
|
"NSUnderlyingError",
|
|
"_kCF"
|
|
]
|
|
// i18n-ignore-end
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Parses error messages to extract user-friendly text
|
|
/// Handles API error codes, network errors, JSON responses, and raw error messages
|
|
/// - Parameter rawMessage: The raw error message from the API or exception
|
|
/// - Returns: A user-friendly error message
|
|
static func parse(_ rawMessage: String) -> String {
|
|
let trimmed = rawMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
// Check for known API error codes first (e.g., "error.invalid_token")
|
|
if trimmed.hasPrefix("error.") {
|
|
if let friendlyMessage = errorCodeMappings[trimmed] {
|
|
return friendlyMessage
|
|
}
|
|
// Unknown error code - generate a generic message from the code
|
|
return generateMessageFromCode(trimmed)
|
|
}
|
|
|
|
// Check for network/connection errors
|
|
for (pattern, friendlyMessage) in networkErrorPatterns {
|
|
if trimmed.localizedCaseInsensitiveContains(pattern) {
|
|
return friendlyMessage
|
|
}
|
|
}
|
|
|
|
// Check if it looks like a technical exception message
|
|
if isTechnicalError(trimmed) {
|
|
return String(localized: "Something went wrong. Please try again.")
|
|
}
|
|
|
|
// Check if the message looks like JSON (starts with { or [)
|
|
guard trimmed.hasPrefix("{") || trimmed.hasPrefix("[") else {
|
|
// Not JSON - if it's a short, readable message, return it
|
|
// Otherwise return a generic message
|
|
return isUserFriendly(trimmed) ? trimmed : String(localized: "Something went wrong. Please try again.")
|
|
}
|
|
|
|
// If it's JSON, try to parse and extract meaningful error info
|
|
guard let data = trimmed.data(using: .utf8) else {
|
|
return String(localized: "An error occurred. Please try again.")
|
|
}
|
|
|
|
do {
|
|
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
|
// Try to find common error fields
|
|
if let errorMsg = json["error"] as? String {
|
|
// Recursively parse the error message (it might be an error code)
|
|
return parse(errorMsg)
|
|
}
|
|
if let message = json["message"] as? String {
|
|
return parse(message)
|
|
}
|
|
if let detail = json["detail"] as? String {
|
|
return parse(detail)
|
|
}
|
|
|
|
// Check if this looks like a data object (has id, title, etc)
|
|
if json["id"] != nil && (json["title"] != nil || json["name"] != nil) {
|
|
return String(localized: "Request failed. Please check your input and try again.")
|
|
}
|
|
}
|
|
} catch {
|
|
// JSON parsing failed
|
|
}
|
|
|
|
// If we couldn't parse or extract a message, return a generic error
|
|
return String(localized: "An error occurred. Please try again.")
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Generates a user-friendly message from an unknown error code
|
|
private static func generateMessageFromCode(_ code: String) -> String {
|
|
// Remove "error." prefix and convert underscores to spaces
|
|
var message = code.replacingOccurrences(of: "error.", with: "") // i18n-ignore: error-code key prefix (non-UI)
|
|
message = message.replacingOccurrences(of: "_", with: " ")
|
|
|
|
// Capitalize first letter
|
|
if let firstChar = message.first {
|
|
message = firstChar.uppercased() + message.dropFirst()
|
|
}
|
|
|
|
return String(format: String(localized: "%@. Please try again."), message)
|
|
}
|
|
|
|
/// Checks if the message looks like a technical/developer error (stack trace, exception, etc)
|
|
private static func isTechnicalError(_ message: String) -> Bool {
|
|
return technicalIndicators.contains { message.localizedCaseInsensitiveContains($0) }
|
|
}
|
|
|
|
/// Checks if a message is user-friendly (short, no technical jargon)
|
|
private static func isUserFriendly(_ message: String) -> Bool {
|
|
// If it's short and doesn't contain technical indicators, it's probably user-friendly
|
|
return message.count < 200 && !isTechnicalError(message)
|
|
}
|
|
}
|