Signal-iOS/SignalServiceKit/Contacts/PhoneNumberUtil.swift
2024-04-02 16:08:58 -05:00

759 lines
25 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import libPhoneNumber_iOS
import SignalCoreKit
public class PhoneNumberUtilSwiftValues {
fileprivate let nbPhoneNumberUtil = NBPhoneNumberUtil()
fileprivate let countryCodesFromCallingCodeCache = AtomicDictionary<String, [String]>([:], lock: .init())
fileprivate let parsedPhoneNumberCache = LRUCache<String, NBPhoneNumber>(
maxSize: 256,
nseMaxSize: 0,
shouldEvacuateInBackground: false
)
fileprivate let nationalPrefixTransformRuleCache = AtomicDictionary<String, String?>([:], lock: .init())
fileprivate let phoneNumberCountryCodeCache = AtomicDictionary<String, String?>([:], lock: .init())
}
// MARK: -
extension PhoneNumberUtil {
// country code -> calling code
public func callingCode(fromCountryCode countryCode: String) -> String? {
guard let countryCode = countryCode.nilIfEmpty else {
return "+0"
}
if countryCode == "AQ" {
// Antarctica
return "+672"
} else if countryCode == "BV" {
// Bouvet Island
return "+55"
} else if countryCode == "IC" {
// Canary Islands
return "+34"
} else if countryCode == "EA" {
// Ceuta & Melilla
return "+34"
} else if countryCode == "CP" {
// Clipperton Island
//
// This country code should be filtered - it does not appear to have a calling code.
return nil
} else if countryCode == "DG" {
// Diego Garcia
return "+246"
} else if countryCode == "TF" {
// French Southern Territories
return "+262"
} else if countryCode == "HM" {
// Heard & McDonald Islands
return "+672"
} else if countryCode == "XK" {
// Kosovo
return "+383"
} else if countryCode == "PN" {
// Pitcairn Islands
return "+64"
} else if countryCode == "GS" {
// So. Georgia & So. Sandwich Isl.
return "+500"
} else if countryCode == "UM" {
// U.S. Outlying Islands
return "+1"
}
let callingCode = getCallingCode(forRegion: countryCode)
return COUNTRY_CODE_PREFIX + "\(callingCode.intValue)"
}
@objc
public static func defaultCountryCode() -> String {
return Locale.current.regionCode ?? "US"
}
// Returns a list of country codes for a calling code in descending
// order of population.
public func countryCodes(fromCallingCode callingCode: String) -> [String] {
guard let callingCode = callingCode.nilIfEmpty else {
return []
}
if let cachedValue = swiftValues.countryCodesFromCallingCodeCache[callingCode] {
return cachedValue
}
let countryCodes: [String] = Self.countryCodesSortedByPopulationDescending.compactMap { (countryCode: String) -> String? in
let callingCodeForCountryCode: String? = self.callingCode(fromCountryCode: countryCode)
guard callingCode == callingCodeForCountryCode else {
return nil
}
return countryCode
}
swiftValues.countryCodesFromCallingCodeCache[callingCode] = countryCodes
return countryCodes
}
@objc
public func format(_ phoneNumber: NBPhoneNumber, numberFormat: NBEPhoneNumberFormat) throws -> String {
try swiftValues.nbPhoneNumberUtil.format(phoneNumber, numberFormat: numberFormat)
}
@objc
public func parse(_ numberToParse: String, defaultRegion: String) throws -> NBPhoneNumber {
let hashKey = "numberToParse:\(numberToParse), defaultRegion:\(defaultRegion)"
if let cachedValue = swiftValues.parsedPhoneNumberCache[hashKey] {
return cachedValue
}
let result = try swiftValues.nbPhoneNumberUtil.parse(numberToParse, defaultRegion: defaultRegion)
swiftValues.parsedPhoneNumberCache[hashKey] = result
return result
}
public func examplePhoneNumber(forCountryCode countryCode: String) -> String? {
// Signal users are very likely using mobile devices, so prefer that kind of example.
do {
func findExamplePhoneNumber() -> NBPhoneNumber? {
let types: [NBEPhoneNumberType] = [.MOBILE, .FIXED_LINE_OR_MOBILE]
for type in types {
let phoneNumber = PhoneNumberUtil.getExampleNumber(
forType: countryCode,
type: type,
nbPhoneNumberUtil: swiftValues.nbPhoneNumberUtil
)
if let phoneNumber {
return phoneNumber
}
}
return nil
}
guard let nbPhoneNumber = findExamplePhoneNumber() else {
owsFailDebug("Could not find example phone number for: \(countryCode)")
return nil
}
return try swiftValues.nbPhoneNumberUtil.format(nbPhoneNumber, numberFormat: .E164)
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
public func isPossibleNumber(_ number: NBPhoneNumber) -> Bool {
return swiftValues.nbPhoneNumberUtil.isPossibleNumber(number)
}
@objc
public func getRegionCodeForCountryCode(_ countryCallingCode: NSNumber) -> String {
return swiftValues.nbPhoneNumberUtil.getRegionCode(forCountryCode: countryCallingCode)
}
@objc
public func isValidNumber(_ number: NBPhoneNumber) -> Bool {
return swiftValues.nbPhoneNumberUtil.isValidNumber(number)
}
public func getCallingCode(forRegion regionCode: String) -> NSNumber {
return swiftValues.nbPhoneNumberUtil.getCountryCode(forRegion: regionCode)
}
@objc
public func nationalNumber(for phoneNumber: PhoneNumber) -> String {
return swiftValues.nbPhoneNumberUtil.getNationalSignificantNumber(phoneNumber.nbPhoneNumber)
}
@objc
public func formattedNationalNumber(for phoneNumber: PhoneNumber) -> String? {
return try? format(phoneNumber.nbPhoneNumber, numberFormat: .NATIONAL)
}
/// Returns the most likely country code for a calling code based on population.
/// If no country codes are found, returns the empty string.
@objc
public func probableCountryCode(forCallingCode callingCode: String) -> String {
return countryCodes(fromCallingCode: callingCode).first ?? ""
}
private class func does(_ string: String, matchQuery query: String) -> Bool {
let searchOptions: String.CompareOptions = [.caseInsensitive, .anchored]
let stringTokens = string.components(separatedBy: .whitespaces)
let queryTokens = query.components(separatedBy: .whitespaces)
return queryTokens.allSatisfy { queryToken in
if queryToken.isEmpty {
return true
}
return stringTokens.contains { stringToken in
stringToken.range(of: queryToken, options: searchOptions) != nil
}
}
}
/// Get country codes from a search term.
@objc
public func countryCodes(forSearchTerm searchTerm: String?) -> [String] {
let cleanedSearch = (searchTerm ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let codes = NSLocale.isoCountryCodes.filter { countryCode in
guard
let callingCode = callingCode(fromCountryCode: countryCode),
!callingCode.isEmpty,
callingCode != "+0"
else {
return false
}
let countryName = Self.countryName(fromCountryCode: countryCode)
return (
cleanedSearch.isEmpty ||
Self.does(countryName, matchQuery: cleanedSearch) ||
Self.does(countryCode, matchQuery: cleanedSearch) ||
callingCode.contains(cleanedSearch)
)
}
return codes.sorted { lhs, rhs in
let lhsCountry = Self.countryName(fromCountryCode: lhs)
let rhsCountry = Self.countryName(fromCountryCode: rhs)
return lhsCountry.localizedCaseInsensitiveCompare(rhsCountry) == .orderedAscending
}
}
/// Convert country code to country name.
@objc
public class func countryName(fromCountryCode countryCode: String) -> String {
lazy var unknownValue = OWSLocalizedString(
"UNKNOWN_VALUE",
comment: "Indicates an unknown or unrecognizable value."
)
if countryCode.isEmpty {
return unknownValue
}
return Locale.current.localizedString(forRegionCode: countryCode)?.nilIfEmpty ?? unknownValue
}
private func parsePhoneNumber(_ numberToParse: String, regionCode: String) -> PhoneNumber? {
do {
let phoneNumber = try parse(numberToParse, defaultRegion: regionCode)
guard isPossibleNumber(phoneNumber) else {
return nil
}
let phoneNumberE164 = try format(phoneNumber, numberFormat: .E164)
return PhoneNumber(nbPhoneNumber: phoneNumber, e164: phoneNumberE164)
} catch {
return nil
}
}
@objc
public func parsePhoneNumber(userSpecifiedText: String) -> PhoneNumber? {
if userSpecifiedText.isEmpty {
return nil
}
let sanitizedText = (userSpecifiedText as NSString).filterAsE164()
return parsePhoneNumber(sanitizedText, regionCode: Self.defaultCountryCode())
}
/// `text` may omit the calling code or duplicate the value in `callingCode`.
public func parsePhoneNumber(userSpecifiedText: String, callingCode: String) -> PhoneNumber? {
if userSpecifiedText.isEmpty {
return nil
}
let regionCode = getRegionCodeForCountryCode(NSNumber(value: (callingCode as NSString).integerValue))
if regionCode == NB_UNKNOWN_REGION {
return parsePhoneNumber(userSpecifiedText: callingCode.appending(userSpecifiedText))
}
var sanitizedText = (userSpecifiedText as NSString).filterAsE164()
if sanitizedText.hasPrefix("+") {
sanitizedText = String(sanitizedText.dropFirst())
}
return parsePhoneNumber(sanitizedText, regionCode: regionCode)
}
@objc
public func parseE164(_ phoneNumberString: String) -> PhoneNumber? {
guard let phoneNumber = E164(phoneNumberString) else {
return nil
}
return parseE164(phoneNumber)
}
public func parseE164(_ phoneNumber: E164) -> PhoneNumber? {
return parsePhoneNumber(phoneNumber.stringValue, regionCode: "ZZ")
}
@objc
public func parsePhoneNumbers(userSpecifiedText: String, localPhoneNumber: String?) -> [PhoneNumber] {
var results = parsePhoneNumbers(normalizedText: userSpecifiedText, localPhoneNumber: localPhoneNumber)
// A handful of countries (Mexico, Argentina, etc.) require a "national"
// prefix after their country calling code.
//
// It's a bit hacky, but we reconstruct these national prefixes from
// libPhoneNumber's parsing logic. It's okay if we botch this a little. The
// risk is that we end up with some misformatted numbers with extra
// non-numeric regex syntax. These erroneously parsed numbers will never be
// presented to the user, since they'll never survive the contacts
// intersection.
//
// Try to apply a "national prefix" using the phone's region and using the
// region that corresponds to the calling code for the local phone number.
let countryCodes: [String] = [
Self.defaultCountryCode(),
localPhoneNumber.flatMap { countryCode(for: $0) },
].compacted().removingDuplicates(uniquingElementsBy: { $0 })
for countryCode in countryCodes {
guard let transformRule = nationalPrefixTransformRule(countryCode: countryCode) else {
continue
}
guard transformRule.contains("$1") else {
continue
}
let normalizedText = transformRule.replacingOccurrences(of: "$1", with: userSpecifiedText)
guard !normalizedText.contains("$") else {
continue
}
results.append(contentsOf: parsePhoneNumbers(
normalizedText: normalizedText,
localPhoneNumber: localPhoneNumber
))
}
return results
}
/// This will try to parse the input text as a phone number using the
/// default region and the country code for this client's phone number.
///
/// Order matters; better results will appear first.
func parsePhoneNumbers(normalizedText: String, localPhoneNumber: String?) -> [PhoneNumber] {
guard let text = (normalizedText as NSString).filterAsE164().nilIfEmpty else {
return []
}
var results = [PhoneNumber]()
var phoneNumbers = Set<String>()
func tryParsing(_ text: String, countryCode: String) {
guard let phoneNumber = parsePhoneNumber(text, regionCode: countryCode) else {
return
}
guard phoneNumbers.insert(phoneNumber.toE164()).inserted else {
return
}
results.append(phoneNumber)
}
let defaultCountryCode = Self.defaultCountryCode()
tryParsing(text, countryCode: defaultCountryCode)
if text.hasPrefix("+") {
// If the text starts with "+", don't try prepending
// anything else.
return results
}
// Try just adding "+" and parsing it.
tryParsing("+" + text, countryCode: defaultCountryCode)
// Order matters; better results should appear first so prefer
// matches with the same country code as this client's phone number.
guard let localPhoneNumber else {
owsFailDebug("localPhoneNumber is missing")
return results
}
// Note that NBPhoneNumber uses "country code" to refer to what we call a
// "calling code" (i.e. 44 in +44123123). Within SSK we use "country code"
// (and sometimes "region code") to refer to a country's ISO 2-letter code
// (ISO 3166-1 alpha-2).
guard let callingCodeForLocalNumber = parseE164(localPhoneNumber)?.getCallingCode() else {
owsFailDebug("callingCodeForLocalNumber is missing")
return results
}
let callingCodePrefix = "+\(callingCodeForLocalNumber)"
tryParsing(callingCodePrefix + text, countryCode: defaultCountryCode)
// Try to determine what the country code is for the local phone number and
// also try parsing the phone number using that country code if it differs
// from the device's region code.
//
// For example, a French person living in Italy might have an Italian phone
// number but use French region/language for their phone. They're likely to
// have both Italian and French contacts.
let localCountryCode = probableCountryCode(forCallingCode: callingCodePrefix)
if localCountryCode != defaultCountryCode {
tryParsing(callingCodePrefix + text, countryCode: localCountryCode)
}
let phoneNumberWithAreaCodeIfMissing = Self.phoneNumberWithAreaCodeIfMissing(
normalizedText: text,
localCallingCode: callingCodeForLocalNumber,
localPhoneNumber: localPhoneNumber
)
if let phoneNumberWithAreaCodeIfMissing {
tryParsing(phoneNumberWithAreaCodeIfMissing, countryCode: localCountryCode)
}
return results
}
/// Adds the local user's area code to `normalizedText` if it doesn't have its own.
private static func phoneNumberWithAreaCodeIfMissing(
normalizedText: String,
localCallingCode: NSNumber,
localPhoneNumber: String
) -> String? {
switch localCallingCode.intValue {
case 1:
return addMissingAreaCode(
normalizedText: normalizedText,
localCallingCode: localCallingCode,
localPhoneNumber: localPhoneNumber,
missingAreaCodeRegex: Self.missingUSAreaCodeRegex,
areaCodeRegex: Self.usAreaCodeRegex
)
case 55:
return addMissingAreaCode(
normalizedText: normalizedText,
localCallingCode: localCallingCode,
localPhoneNumber: localPhoneNumber,
missingAreaCodeRegex: Self.missingBRAreaCodeRegex,
areaCodeRegex: Self.brAreaCodeRegex
)
default:
return nil
}
}
private static let missingUSAreaCodeRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"^(\d{7})$"#)
}()
private static let usAreaCodeRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"^\+1(\d{3})"#)
}()
private static let missingBRAreaCodeRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"^(9?\d{8})$"#)
}()
private static let brAreaCodeRegex: NSRegularExpression = {
return try! NSRegularExpression(pattern: #"^\+55(\d{2})9?\d{8}"#)
}()
private static func addMissingAreaCode(
normalizedText: String,
localCallingCode: NSNumber,
localPhoneNumber: String,
missingAreaCodeRegex: NSRegularExpression,
areaCodeRegex: NSRegularExpression
) -> String? {
let match = missingAreaCodeRegex.firstMatch(in: normalizedText, range: normalizedText.entireRange)
if match == nil {
return nil
}
let localAreaCodeNSRange = areaCodeRegex.matches(
in: localPhoneNumber,
range: NSRange(localPhoneNumber.startIndex..., in: localPhoneNumber)
).first?.range(at: 1)
guard
let localAreaCodeNSRange,
let localAreaCodeRange = Range(localAreaCodeNSRange, in: localPhoneNumber)
else {
return nil
}
return "+\(localCallingCode)\(localPhoneNumber[localAreaCodeRange])\(normalizedText)"
}
private func nationalPrefixTransformRule(countryCode: String) -> String? {
if let transformRule = swiftValues.nationalPrefixTransformRuleCache[countryCode] {
return transformRule
}
let transformRule: String? = NBMetadataHelper().getMetadataForRegion(countryCode)?.nationalPrefixTransformRule
swiftValues.nationalPrefixTransformRuleCache[countryCode] = transformRule
return transformRule
}
private func countryCode(for phoneNumber: String) -> String? {
if let countryCode = swiftValues.phoneNumberCountryCodeCache[phoneNumber] {
return countryCode
}
let countryCode: String? = {
guard let callingCode = parseE164(phoneNumber)?.getCallingCode() else {
return nil
}
return probableCountryCode(forCallingCode: "+\(callingCode)").nilIfEmpty
}()
swiftValues.phoneNumberCountryCodeCache[phoneNumber] = countryCode
return countryCode
}
private static let countryCodesSortedByPopulationDescending: [String] = [
"CN", // 1330044000
"IN", // 1173108018
"US", // 310232863
"ID", // 242968342
"BR", // 201103330
"PK", // 184404791
"BD", // 156118464
"NG", // 154000000
"RU", // 140702000
"JP", // 127288000
"MX", // 112468855
"PH", // 99900177
"VN", // 89571130
"ET", // 88013491
"DE", // 81802257
"EG", // 80471869
"TR", // 77804122
"IR", // 76923300
"CD", // 70916439
"TH", // 67089500
"FR", // 64768389
"GB", // 62348447
"IT", // 60340328
"MM", // 53414374
"ZA", // 49000000
"KR", // 48422644
"CO", // 47790000
"ES", // 46505963
"UA", // 45415596
"TZ", // 41892895
"AR", // 41343201
"KE", // 40046566
"PL", // 38500000
"SD", // 35000000
"DZ", // 34586184
"MA", // 33848242
"CA", // 33679000
"UG", // 33398682
"PE", // 29907003
"IQ", // 29671605
"AF", // 29121286
"NP", // 28951852
"MY", // 28274729
"UZ", // 27865738
"VE", // 27223228
"SA", // 25731776
"GH", // 24339838
"YE", // 23495361
"KP", // 22912177
"TW", // 22894384
"SY", // 22198110
"MZ", // 22061451
"RO", // 21959278
"AU", // 21515754
"LK", // 21513990
"MG", // 21281844
"CI", // 21058798
"CM", // 19294149
"CL", // 16746491
"NL", // 16645000
"BF", // 16241811
"NE", // 15878271
"MW", // 15447500
"KZ", // 15340000
"EC", // 14790608
"KH", // 14453680
"ML", // 13796354
"GT", // 13550440
"ZM", // 13460305
"AO", // 13068161
"ZW", // 13061000
"SN", // 12323252
"CU", // 11423000
"RW", // 11055976
"GR", // 11000000
"PT", // 10676000
"TN", // 10589025
"TD", // 10543464
"CZ", // 10476000
"BE", // 10403000
"GN", // 10324025
"SO", // 10112453
"HU", // 9982000
"BO", // 9947418
"BI", // 9863117
"SE", // 9828655
"DO", // 9823821
"BY", // 9685000
"HT", // 9648924
"BJ", // 9056010
"AZ", // 8303512
"SS", // 8260490
"AT", // 8205000
"HN", // 7989415
"CH", // 7581000
"TJ", // 7487489
"IL", // 7353985
"RS", // 7344847
"BG", // 7148785
"HK", // 6898686
"TG", // 6587239
"LY", // 6461454
"JO", // 6407085
"PY", // 6375830
"LA", // 6368162
"PG", // 6064515
"SV", // 6052064
"NI", // 5995928
"ER", // 5792984
"KG", // 5776500
"DK", // 5484000
"SK", // 5455000
"SL", // 5245695
"FI", // 5244000
"NO", // 5009150
"AE", // 4975593
"TM", // 4940916
"CF", // 4844927
"SG", // 4701069
"GE", // 4630000
"IE", // 4622917
"BA", // 4590000
"CR", // 4516220
"MD", // 4324000
"HR", // 4284889
"NZ", // 4252277
"LB", // 4125247
"PR", // 3916632
"PS", // 3800000
"LR", // 3685076
"UY", // 3477000
"PA", // 3410676
"MR", // 3205060
"MN", // 3086918
"CG", // 3039126
"AL", // 2986952
"AM", // 2968000
"OM", // 2967717
"LT", // 2944459
"JM", // 2847232
"KW", // 2789132
"LV", // 2217969
"NA", // 2128471
"MK", // 2062294
"BW", // 2029307
"SI", // 2007000
"LS", // 1919552
"XK", // 1800000
"GM", // 1593256
"GW", // 1565126
"GA", // 1545255
"SZ", // 1354051
"TT", // 1328019
"MU", // 1294104
"EE", // 1291170
"TL", // 1154625
"CY", // 1102677
"GQ", // 1014999
"FJ", // 875983
"QA", // 840926
"RE", // 776948
"KM", // 773407
"GY", // 748486
"DJ", // 740528
"BH", // 738004
"BT", // 699847
"ME", // 666730
"SB", // 559198
"CV", // 508659
"LU", // 497538
"SR", // 492829
"MO", // 449198
"GP", // 443000
"MQ", // 432900
"MT", // 403000
"MV", // 395650
"BN", // 395027
"BZ", // 314522
"IS", // 308910
"BS", // 301790
"BB", // 285653
"EH", // 273008
"PF", // 270485
"VU", // 221552
"NC", // 216494
"GF", // 195506
"WS", // 192001
"ST", // 175808
"LC", // 160922
"GU", // 159358
"YT", // 159042
"CW", // 141766
"TO", // 122580
"VI", // 108708
"GD", // 107818
"FM", // 107708
"VC", // 104217
"KI", // 92533
"JE", // 90812
"SC", // 88340
"AG", // 86754
"AD", // 84000
"IM", // 75049
"DM", // 72813
"AW", // 71566
"MH", // 65859
"BM", // 65365
"GG", // 65228
"AS", // 57881
"GL", // 56375
"MP", // 53883
"KN", // 51134
"FO", // 48228
"KY", // 44270
"SX", // 37429
"MF", // 35925
"LI", // 35000
"MC", // 32965
"SM", // 31477
"GI", // 27884
"AX", // 26711
"VG", // 21730
"CK", // 21388
"TC", // 20556
"PW", // 19907
"BQ", // 18012
"WF", // 16025
"AI", // 13254
"TV", // 10472
"NR", // 10065
"MS", // 9341
"BL", // 8450
"SH", // 7460
"PM", // 7012
"IO", // 4000
"FK", // 2638
"SJ", // 2550
"NU", // 2166
"NF", // 1828
"CX", // 1500
"TK", // 1466
"VA", // 921
"CC", // 628
"TF", // 140
"PN", // 46
"GS", // 30
"AC", // 0
"AQ", // 0
"BV", // 0
"CP", // 0
"DG", // 0
"EA", // 0
"HM", // 0
"IC", // 0
"TA", // 0
"UM", // 0
]
}