import Foundation import UIKit // MARK: - vCard版本转换工具 struct VCardConverter { /// 将vCard 2.1格式转换为3.0格式 static func convertVCard21To30(_ vcard21: String) -> String { let lines = vcard21.components(separatedBy: .newlines) var vcard30 = "BEGIN:VCARD\nVERSION:3.0\n" for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if trimmedLine.isEmpty || trimmedLine.hasPrefix("BEGIN:") || trimmedLine.hasPrefix("VERSION:") || trimmedLine.hasPrefix("END:") { continue } // 处理N字段 (姓名) if trimmedLine.hasPrefix("N:") { let nameValue = String(trimmedLine.dropFirst(2)) let nameParts = nameValue.components(separatedBy: ";") if nameParts.count >= 2 { let lastName = nameParts[0] let firstName = nameParts[1] vcard30 += "N:\(lastName);\(firstName);;;\n" vcard30 += "FN:\(firstName) \(lastName)\n" } } // 处理TEL字段 (电话) else if trimmedLine.hasPrefix("TEL") { let telValue = String(trimmedLine.dropFirst(3)) if telValue.hasPrefix(";TYPE=") { let typeStart = telValue.firstIndex(of: "=")! let typeEnd = telValue.firstIndex(of: ":") ?? telValue.endIndex let type = String(telValue[telValue.index(after: typeStart).. String { let lines = vcard.components(separatedBy: .newlines) for line in lines { if line.hasPrefix("VERSION:") { return String(line.dropFirst(8)) } } return "3.0" // 默认版本 } /// 标准化vCard为3.0版本 static func normalizeVCard(_ vcard: String) -> String { let version = detectVCardVersion(vcard) if version == "2.1" { return convertVCard21To30(vcard) } return vcard } /// 测试vCard版本转换功能 static func testVCardConversion() { let vcard21 = """ BEGIN:VCARD VERSION:2.1 N:Surname;Givenname;;; FN:Givenname Surname TEL;TYPE=WORK:123-456-7890 EMAIL;TYPE=PREF:test@example.com END:VCARD """ let converted = convertVCard21To30(vcard21) print("Original vCard 2.1:") print(vcard21) print("\nConverted to vCard 3.0:") print(converted) } } // MARK: - 二维码解析器 class QRCodeParser { // MARK: - 解析二维码内容 static func parseQRCode(_ content: String) -> ParsedQRData { let trimmedContent = content.trimmingCharacters(in: .whitespacesAndNewlines) // Wi-Fi if trimmedContent.uppercased().hasPrefix("WIFI:") { return parseWiFi(trimmedContent) } // Email if trimmedContent.lowercased().hasPrefix("mailto:") { return parseEmail(trimmedContent) } // Phone if trimmedContent.lowercased().hasPrefix("tel:") { return parsePhone(trimmedContent) } // SMS if trimmedContent.uppercased().hasPrefix("SMSTO:") { return parseSMS(trimmedContent) } // vCard if trimmedContent.uppercased().hasPrefix("BEGIN:VCARD") { return parseVCard(trimmedContent) } // MeCard if trimmedContent.uppercased().hasPrefix("MECARD:") { return parseMeCard(trimmedContent) } // Calendar if trimmedContent.uppercased().hasPrefix("BEGIN:VEVENT") { return parseCalendar(trimmedContent) } // Instagram if trimmedContent.lowercased().hasPrefix("instagram://user?username=") { return parseInstagram(trimmedContent) } // Facebook if trimmedContent.lowercased().hasPrefix("fb://profile/") { return parseFacebook(trimmedContent) } // Spotify if trimmedContent.lowercased().hasPrefix("spotify:search:") { return parseSpotify(trimmedContent) } // X (Twitter) if trimmedContent.lowercased().hasPrefix("twitter://user?screen_name=") || trimmedContent.lowercased().contains("x.com") || trimmedContent.lowercased().contains("twitter.com") { return parseTwitter(trimmedContent) } // WhatsApp if trimmedContent.lowercased().hasPrefix("whatsapp://send?phone=") { return parseWhatsApp(trimmedContent) } // Viber if trimmedContent.lowercased().hasPrefix("viber://add?number=") { return parseViber(trimmedContent) } // Snapchat if trimmedContent.lowercased().hasPrefix("snapchat://") { return parseSnapchat(trimmedContent) } // TikTok if trimmedContent.lowercased().contains("tiktok.com") || trimmedContent.lowercased().contains("www.tiktok.com") { return parseTikTok(trimmedContent) } // URL (检查是否为有效的URL) if isValidURL(trimmedContent) { return parseURL(trimmedContent) } // Location if trimmedContent.lowercased().hasPrefix("geo:") { return parseLocation(trimmedContent) } // 默认为文本类型 return ParsedQRData( type: .text, title: "text_information".localized, subtitle: trimmedContent.count > 50 ? String(trimmedContent.prefix(50)) + "..." : trimmedContent, icon: "text.quote" ) } // MARK: - 解析Wi-Fi private static func parseWiFi(_ content: String) -> ParsedQRData { let wifiInfo = content.replacingOccurrences(of: "WIFI:", with: "", options: .caseInsensitive) let components = wifiInfo.components(separatedBy: ";") var ssid = "" var password = "" var encryption = "WPA" for component in components { if component.uppercased().hasPrefix("S:") { ssid = String(component.dropFirst(2)) } else if component.uppercased().hasPrefix("P:") { password = String(component.dropFirst(2)) } else if component.uppercased().hasPrefix("T:") { encryption = String(component.dropFirst(2)) } } let title = "wifi_network".localized let subtitle = String(format: "wifi_network_info".localized, ssid, encryption, password) // 存储WiFi详细信息到额外数据中 let wifiDetails = WiFiDetails(ssid: ssid, password: password, encryption: encryption) let extraData = try? JSONEncoder().encode(wifiDetails) return ParsedQRData( type: .wifi, title: title, subtitle: subtitle, icon: "wifi", extraData: extraData ) } // MARK: - 解析Email private static func parseEmail(_ content: String) -> ParsedQRData { let emailInfo = content.replacingOccurrences(of: "mailto:", with: "", options: .caseInsensitive) let components = emailInfo.components(separatedBy: "?") let emailAddress = components.first ?? "" var subject = "" var body = "" // 解析查询参数 if components.count > 1 { let queryString = components[1] let queryParams = queryString.components(separatedBy: "&") for param in queryParams { let keyValue = param.components(separatedBy: "=") if keyValue.count == 2 { let key = keyValue[0].lowercased() let value = keyValue[1].removingPercentEncoding ?? keyValue[1] if key == "subject" { subject = value } else if key == "body" { body = value } } } } // 格式化显示信息 var subtitle = emailAddress if !subject.isEmpty { subtitle += String(format: "\n主题: %@", subject) } if !body.isEmpty { let truncatedBody = body.count > 100 ? String(body.prefix(100)) + "..." : body subtitle += String(format: "\n内容: %@", truncatedBody) } // 存储Email详细信息到额外数据中 let emailDetails = EmailDetails(emailAddress: emailAddress, subject: subject, body: body) let extraData = try? JSONEncoder().encode(emailDetails) return ParsedQRData( type: .mail, title: "email_address".localized, subtitle: subtitle, icon: "envelope", extraData: extraData ) } // MARK: - 解析Phone private static func parsePhone(_ content: String) -> ParsedQRData { let phone = content.replacingOccurrences(of: "tel:", with: "", options: .caseInsensitive) return ParsedQRData( type: .phone, title: "phone_number".localized, subtitle: phone, icon: "phone" ) } // MARK: - 解析SMS private static func parseSMS(_ content: String) -> ParsedQRData { let smsInfo = content.replacingOccurrences(of: "SMSTO:", with: "", options: .caseInsensitive) let components = smsInfo.components(separatedBy: ":") let phone = components.first?.trimmingCharacters(in: .whitespaces) ?? "" let message = components.count > 1 ? components[1].trimmingCharacters(in: .whitespaces) : "" let title = "sms".localized var subtitle = "" // 构建更美观的副标题 if !phone.isEmpty { subtitle += String(format: "📱 %@", phone) } if !message.isEmpty { if !subtitle.isEmpty { subtitle += "\n" } let truncatedMessage = message.count > 100 ? String(message.prefix(100)) + "..." : message subtitle += String(format: "💬 %@", truncatedMessage) } // 创建SMS信息结构体 let smsDetails = SMSDetails(phoneNumber: phone, message: message) let extraData = try? JSONEncoder().encode(smsDetails) return ParsedQRData( type: .sms, title: title, subtitle: subtitle, icon: "message", extraData: extraData ) } // MARK: - 解析vCard private static func parseVCard(_ content: String) -> ParsedQRData { // 标准化vCard为3.0版本 let normalizedVCard = VCardConverter.normalizeVCard(content) let contactInfo = parseContactInfoFromVCard(normalizedVCard) var subtitle = "" if !contactInfo.name.isEmpty { subtitle += String(format: "contact_name".localized, contactInfo.name) + "\n" } if !contactInfo.phoneNumber.isEmpty { subtitle += String(format: "contact_phone".localized, contactInfo.phoneNumber) + "\n" } if !contactInfo.email.isEmpty { subtitle += String(format: "contact_email".localized, contactInfo.email) + "\n" } if !contactInfo.organization.isEmpty { subtitle += String(format: "contact_company".localized, contactInfo.organization) + "\n" } if !contactInfo.title.isEmpty { subtitle += String(format: "contact_title".localized, contactInfo.title) + "\n" } if !contactInfo.address.isEmpty { subtitle += String(format: "contact_address".localized, contactInfo.address) + "\n" } // 移除最后一个换行符 if subtitle.hasSuffix("\n") { subtitle = String(subtitle.dropLast()) } // 存储联系人信息到额外数据中 let extraData = try? JSONEncoder().encode(contactInfo) return ParsedQRData( type: .vcard, title: "contact_information".localized, subtitle: subtitle, icon: "person.crop.rectangle", extraData: extraData ) } // MARK: - 解析MeCard private static func parseMeCard(_ content: String) -> ParsedQRData { let contactInfo = parseContactInfoFromMeCard(content) var subtitle = "" if !contactInfo.name.isEmpty { subtitle += String(format: "contact_name".localized, contactInfo.name) + "\n" } if !contactInfo.phoneNumber.isEmpty { subtitle += String(format: "contact_phone".localized, contactInfo.phoneNumber) + "\n" } if !contactInfo.email.isEmpty { subtitle += String(format: "contact_email".localized, contactInfo.email) + "\n" } if !contactInfo.organization.isEmpty { subtitle += String(format: "contact_company".localized, contactInfo.organization) + "\n" } if !contactInfo.title.isEmpty { subtitle += String(format: "contact_title".localized, contactInfo.title) + "\n" } if !contactInfo.address.isEmpty { subtitle += String(format: "contact_address".localized, contactInfo.address) + "\n" } // 移除最后一个换行符 if subtitle.hasSuffix("\n") { subtitle = String(subtitle.dropLast()) } // 存储联系人信息到额外数据中 let extraData = try? JSONEncoder().encode(contactInfo) return ParsedQRData( type: .mecard, title: "contact_information".localized, subtitle: subtitle, icon: "person.crop.rectangle", extraData: extraData ) } // MARK: - 解析Calendar private static func parseCalendar(_ content: String) -> ParsedQRData { let lines = content.components(separatedBy: .newlines) var summary = "" var startTime = "" var endTime = "" var location = "" var description = "" for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if trimmedLine.uppercased().hasPrefix("SUMMARY:") { summary = String(trimmedLine.dropFirst(8)).trimmingCharacters(in: .whitespaces) } else if trimmedLine.uppercased().hasPrefix("DTSTART:") { startTime = String(trimmedLine.dropFirst(8)).trimmingCharacters(in: .whitespaces) } else if trimmedLine.uppercased().hasPrefix("DTEND:") { endTime = String(trimmedLine.dropFirst(6)).trimmingCharacters(in: .whitespaces) } else if trimmedLine.uppercased().hasPrefix("LOCATION:") { location = String(trimmedLine.dropFirst(9)).trimmingCharacters(in: .whitespaces) } else if trimmedLine.uppercased().hasPrefix("DESCRIPTION:") { description = String(trimmedLine.dropFirst(12)).trimmingCharacters(in: .whitespaces) } } // 格式化时间显示 let formattedStartTime = formatCalendarTime(startTime) let formattedEndTime = formatCalendarTime(endTime) let title = "calendar_event".localized var subtitle = "" // 构建更美观的副标题 if !summary.isEmpty { subtitle += summary } if !formattedStartTime.isEmpty { if !subtitle.isEmpty { subtitle += "\n" } subtitle += String(format: "📅 %@", formattedStartTime) } if !formattedEndTime.isEmpty && formattedEndTime != formattedStartTime { subtitle += String(format: " - %@", formattedEndTime) } if !location.isEmpty { if !subtitle.isEmpty { subtitle += "\n" } subtitle += String(format: "📍 %@", location) } if !description.isEmpty { if !subtitle.isEmpty { subtitle += "\n" } let truncatedDescription = description.count > 100 ? String(description.prefix(100)) + "..." : description subtitle += String(format: "📝 %@", truncatedDescription) } // 存储日历详细信息到额外数据中 let calendarDetails = CalendarDetails(summary: summary, startTime: startTime, endTime: endTime, location: location, description: description) let extraData = try? JSONEncoder().encode(calendarDetails) return ParsedQRData( type: .calendar, title: title, subtitle: subtitle, icon: "calendar", extraData: extraData ) } // MARK: - 格式化日历时间 private static func formatCalendarTime(_ timeString: String) -> String { guard !timeString.isEmpty else { return "" } let dateFormatter = DateFormatter() // 尝试不同的时间格式 let formats = [ "yyyyMMdd'T'HHmmss", // 标准格式:20241201T140000 "yyyyMMdd'T'HHmmss'Z'", // 带Z的格式:20241201T140000Z "yyyyMMdd", // 仅日期:20241201 "yyyyMMdd'T'HHmm", // 无秒:20241201T1400 "yyyy-MM-dd'T'HH:mm:ss", // ISO格式:2024-12-01T14:00:00 "yyyy-MM-dd'T'HH:mm:ss'Z'" // ISO格式带Z:2024-12-01T14:00:00Z ] for format in formats { dateFormatter.dateFormat = format if let date = dateFormatter.date(from: timeString) { let displayFormatter = DateFormatter() displayFormatter.dateStyle = .medium displayFormatter.timeStyle = .short return displayFormatter.string(from: date) } } // 如果无法解析,返回原始字符串 return timeString } // MARK: - 解析Instagram private static func parseInstagram(_ content: String) -> ParsedQRData { var username = "" if content.lowercased().hasPrefix("instagram://user?username=") { username = content.replacingOccurrences(of: "instagram://user?username=", with: "", options: .caseInsensitive) } else if content.lowercased().contains("instagram.com") { // 从网页URL中提取用户名 let components = content.components(separatedBy: "/") if let lastComponent = components.last, !lastComponent.isEmpty && !lastComponent.contains("?") { username = lastComponent } } return ParsedQRData( type: .instagram, title: "instagram".localized, subtitle: String(format: "instagram_username".localized, username), icon: "camera" ) } // MARK: - 解析Facebook private static func parseFacebook(_ content: String) -> ParsedQRData { var profileId = "" if content.lowercased().hasPrefix("fb://profile/") { profileId = content.replacingOccurrences(of: "fb://profile/", with: "", options: .caseInsensitive) } else if content.lowercased().contains("facebook.com") { // 从网页URL中提取用户ID let components = content.components(separatedBy: "/") if let lastComponent = components.last, !lastComponent.isEmpty && !lastComponent.contains("?") { profileId = lastComponent } } return ParsedQRData( type: .facebook, title: "facebook".localized, subtitle: String(format: "facebook_profile_id".localized, profileId), icon: "person.2" ) } // MARK: - 解析Spotify private static func parseSpotify(_ content: String) -> ParsedQRData { var subtitle = "" if content.lowercased().hasPrefix("spotify:search:") { let searchQuery = content.replacingOccurrences(of: "spotify:search:", with: "", options: .caseInsensitive) subtitle = String(format: "spotify_search_query".localized, searchQuery) } else if content.lowercased().hasPrefix("spotify:track:") { let trackId = content.replacingOccurrences(of: "spotify:track:", with: "", options: .caseInsensitive) subtitle = String(format: "spotify_track".localized, trackId) } else if content.lowercased().hasPrefix("spotify:album:") { let albumId = content.replacingOccurrences(of: "spotify:album:", with: "", options: .caseInsensitive) subtitle = String(format: "spotify_album".localized, albumId) } else if content.lowercased().hasPrefix("spotify:artist:") { let artistId = content.replacingOccurrences(of: "spotify:artist:", with: "", options: .caseInsensitive) subtitle = String(format: "spotify_artist".localized, artistId) } else if content.lowercased().hasPrefix("spotify:playlist:") { let playlistId = content.replacingOccurrences(of: "spotify:playlist:", with: "", options: .caseInsensitive) subtitle = String(format: "spotify_playlist".localized, playlistId) } else if content.lowercased().contains("open.spotify.com") { // 从网页URL中提取信息 if content.lowercased().contains("/track/") { let trackId = content.components(separatedBy: "/track/").last?.components(separatedBy: "?").first ?? "" subtitle = String(format: "spotify_track".localized, trackId) } else if content.lowercased().contains("/album/") { let albumId = content.components(separatedBy: "/album/").last?.components(separatedBy: "?").first ?? "" subtitle = String(format: "spotify_album".localized, albumId) } else if content.lowercased().contains("/artist/") { let artistId = content.components(separatedBy: "/artist/").last?.components(separatedBy: "?").first ?? "" subtitle = String(format: "spotify_artist".localized, artistId) } else if content.lowercased().contains("/playlist/") { let playlistId = content.components(separatedBy: "/playlist/").last?.components(separatedBy: "?").first ?? "" subtitle = String(format: "spotify_playlist".localized, playlistId) } } return ParsedQRData( type: .spotify, title: "spotify".localized, subtitle: subtitle, icon: "music.note" ) } // MARK: - 解析X (Twitter) private static func parseTwitter(_ content: String) -> ParsedQRData { var username = "" if content.lowercased().hasPrefix("twitter://user?screen_name=") { username = content.replacingOccurrences(of: "twitter://user?screen_name=", with: "", options: .caseInsensitive) } else if content.lowercased().contains("x.com") || content.lowercased().contains("twitter.com") { username = content.components(separatedBy: "/").dropLast().last ?? "" } return ParsedQRData( type: .twitter, title: "x".localized, subtitle: String(format: "twitter_username".localized, username), icon: "bird" ) } // MARK: - 解析WhatsApp private static func parseWhatsApp(_ content: String) -> ParsedQRData { let phone = content.replacingOccurrences(of: "whatsapp://send?phone=", with: "", options: .caseInsensitive) return ParsedQRData( type: .whatsapp, title: "whatsapp".localized, subtitle: String(format: "whatsapp_phone_number".localized, phone), icon: "message.circle" ) } // MARK: - 解析Viber private static func parseViber(_ content: String) -> ParsedQRData { let phone = content.replacingOccurrences(of: "viber://add?number=", with: "", options: .caseInsensitive) return ParsedQRData( type: .viber, title: "viber".localized, subtitle: String(format: "viber_phone_number".localized, phone), icon: "bubble.left.and.bubble.right" ) } // MARK: - 解析Snapchat private static func parseSnapchat(_ content: String) -> ParsedQRData { let username = content.replacingOccurrences(of: "snapchat://", with: "", options: .caseInsensitive) return ParsedQRData( type: .snapchat, title: "snapchat".localized, subtitle: String(format: "snapchat_username".localized, username), icon: "camera.viewfinder" ) } // MARK: - 解析TikTok private static func parseTikTok(_ content: String) -> ParsedQRData { var username = "" if content.lowercased().contains("www.tiktok.com") { // 处理 https://www.tiktok.com/@username 格式 if let atIndex = content.lastIndex(of: "@") { username = String(content[content.index(after: atIndex)...]) } } else if content.lowercased().contains("tiktok.com") { // 处理 https://tiktok.com/@username 格式 if let atIndex = content.lastIndex(of: "@") { username = String(content[content.index(after: atIndex)...]) } } return ParsedQRData( type: .tiktok, title: "tiktok".localized, subtitle: String(format: "tiktok_username".localized, username), icon: "music.mic" ) } // MARK: - 解析URL private static func parseURL(_ content: String) -> ParsedQRData { return ParsedQRData( type: .url, title: "url_link".localized, subtitle: content, icon: "link" ) } // MARK: - 解析Location private static func parseLocation(_ content: String) -> ParsedQRData { let coordinates = content.replacingOccurrences(of: "geo:", with: "") let coords = coordinates.components(separatedBy: ",") let latitude = coords.first ?? "" let longitude = coords.count > 1 ? coords[1] : "" let title = "geolocation".localized let subtitle = String(format: "geolocation_coordinates".localized, latitude, longitude) return ParsedQRData( type: .location, title: title, subtitle: subtitle, icon: "location" ) } // MARK: - 联系人信息解析方法 /// 从vCard内容解析联系人信息 static func parseContactInfoFromVCard(_ content: String) -> ContactInfo { let lines = content.components(separatedBy: .newlines) var contactInfo = ContactInfo() for line in lines { let trimmedLine = line.trimmingCharacters(in: .whitespaces) if trimmedLine.hasPrefix("FN:") { contactInfo.name = String(trimmedLine.dropFirst(3)) } else if trimmedLine.hasPrefix("TEL") { let telValue = String(trimmedLine.dropFirst(3)) if telValue.contains(":") { let number = telValue.components(separatedBy: ":").last ?? "" contactInfo.phoneNumber = number } } else if trimmedLine.hasPrefix("EMAIL") { let emailValue = String(trimmedLine.dropFirst(5)) if emailValue.contains(":") { let emailAddress = emailValue.components(separatedBy: ":").last ?? "" contactInfo.email = emailAddress } } else if trimmedLine.hasPrefix("ORG:") { contactInfo.organization = String(trimmedLine.dropFirst(4)) } else if trimmedLine.hasPrefix("TITLE:") { contactInfo.title = String(trimmedLine.dropFirst(6)) } else if trimmedLine.hasPrefix("ADR") { let adrValue = String(trimmedLine.dropFirst(3)) if adrValue.contains(":") { let addressParts = adrValue.components(separatedBy: ":") if addressParts.count > 1 { let addressComponents = addressParts[1].components(separatedBy: ";") if addressComponents.count >= 3 { contactInfo.address = "\(addressComponents[2]) \(addressComponents[1])" } } } } } return contactInfo } /// 从MeCard内容解析联系人信息 static func parseContactInfoFromMeCard(_ content: String) -> ContactInfo { let mecardInfo = content.replacingOccurrences(of: "MECARD:", with: "") let components = mecardInfo.components(separatedBy: ";") var contactInfo = ContactInfo() for component in components { let trimmedComponent = component.trimmingCharacters(in: .whitespaces) if trimmedComponent.isEmpty { continue } if trimmedComponent.hasPrefix("N:") { let nameValue = String(trimmedComponent.dropFirst(2)) let nameParts = nameValue.components(separatedBy: ",") if nameParts.count >= 2 { let lastName = nameParts[0] let firstName = nameParts[1] contactInfo.name = "\(firstName) \(lastName)" } else if nameParts.count == 1 { contactInfo.name = nameParts[0] } } else if trimmedComponent.hasPrefix("TEL:") { contactInfo.phoneNumber = String(trimmedComponent.dropFirst(4)) } else if trimmedComponent.hasPrefix("EMAIL:") { contactInfo.email = String(trimmedComponent.dropFirst(6)) } else if trimmedComponent.hasPrefix("ORG:") { contactInfo.organization = String(trimmedComponent.dropFirst(4)) } else if trimmedComponent.hasPrefix("TITLE:") { contactInfo.title = String(trimmedComponent.dropFirst(6)) } else if trimmedComponent.hasPrefix("ADR:") { contactInfo.address = String(trimmedComponent.dropFirst(4)) } } return contactInfo } /// 通用联系人信息解析方法 static func parseContactInfo(from content: String) -> ContactInfo? { if content.hasPrefix("BEGIN:VCARD") { let normalizedVCard = VCardConverter.normalizeVCard(content) let contactInfo = parseContactInfoFromVCard(normalizedVCard) return (contactInfo.name.isEmpty && contactInfo.phoneNumber.isEmpty) ? nil : contactInfo } else if content.hasPrefix("MECARD:") { let contactInfo = parseContactInfoFromMeCard(content) return (contactInfo.name.isEmpty && contactInfo.phoneNumber.isEmpty) ? nil : contactInfo } return nil } // MARK: - 验证URL private static func isValidURL(_ string: String) -> Bool { guard let url = URL(string: string) else { return false } return UIApplication.shared.canOpenURL(url) } }