matcherAPI = $matcherAPI; // TODO: Create ShortNumberInfo for a given map $this->countryCallingCodeToRegionCodeMap = CountryCodeToRegionCodeMap::$countryCodeToRegionCodeMap; $this->currentFilePrefix = dirname(__FILE__) . '/data/' . static::META_DATA_FILE_PREFIX; // Initialise PhoneNumberUtil to make sure regex's are setup correctly PhoneNumberUtil::getInstance(); } /** * Returns the singleton instance of ShortNumberInfo * * @return \libphonenumber\ShortNumberInfo */ public static function getInstance() { if (null === static::$instance) { static::$instance = new self(RegexBasedMatcher::create()); } return static::$instance; } public static function resetInstance() { static::$instance = null; } /** * Returns a list with teh region codes that match the specific country calling code. For * non-geographical country calling codes, the region code 001 is returned. Also, in the case * of no region code being found, an empty list is returned. * * @param int $countryCallingCode * @return array */ protected function getRegionCodesForCountryCode($countryCallingCode) { if (!array_key_exists($countryCallingCode, $this->countryCallingCodeToRegionCodeMap)) { $regionCodes = null; } else { $regionCodes = $this->countryCallingCodeToRegionCodeMap[$countryCallingCode]; } return ($regionCodes === null) ? array() : $regionCodes; } /** * Helper method to check that the country calling code of the number matches the region it's * being dialed from. * @param PhoneNumber $number * @param string $regionDialingFrom * @return bool */ protected function regionDialingFromMatchesNumber(PhoneNumber $number, $regionDialingFrom) { $regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode()); return in_array($regionDialingFrom, $regionCodes); } public function getSupportedRegions() { return ShortNumbersRegionCodeSet::$shortNumbersRegionCodeSet; } /** * Gets a valid short number for the specified region. * * @param $regionCode String the region for which an example short number is needed * @return string a valid short number for the specified region. Returns an empty string when the * metadata does not contain such information. */ public function getExampleShortNumber($regionCode) { $phoneMetadata = $this->getMetadataForRegion($regionCode); if ($phoneMetadata === null) { return ''; } /** @var PhoneNumberDesc $desc */ $desc = $phoneMetadata->getShortCode(); if ($desc !== null && $desc->hasExampleNumber()) { return $desc->getExampleNumber(); } return ''; } /** * @param $regionCode * @return PhoneMetadata|null */ public function getMetadataForRegion($regionCode) { if (!in_array($regionCode, ShortNumbersRegionCodeSet::$shortNumbersRegionCodeSet)) { return null; } if (!isset($this->regionToMetadataMap[$regionCode])) { // The regionCode here will be valid and won't be '001', so we don't need to worry about // what to pass in for the country calling code. $this->loadMetadataFromFile($this->currentFilePrefix, $regionCode, 0); } return isset($this->regionToMetadataMap[$regionCode]) ? $this->regionToMetadataMap[$regionCode] : null; } protected function loadMetadataFromFile($filePrefix, $regionCode, $countryCallingCode) { $isNonGeoRegion = PhoneNumberUtil::REGION_CODE_FOR_NON_GEO_ENTITY === $regionCode; $fileName = $filePrefix . '_' . ($isNonGeoRegion ? $countryCallingCode : $regionCode) . '.php'; if (!is_readable($fileName)) { throw new \Exception('missing metadata: ' . $fileName); } $metadataLoader = new DefaultMetadataLoader(); $data = $metadataLoader->loadMetadata($fileName); $metadata = new PhoneMetadata(); $metadata->fromArray($data); if ($isNonGeoRegion) { $this->countryCodeToNonGeographicalMetadataMap[$countryCallingCode] = $metadata; } else { $this->regionToMetadataMap[$regionCode] = $metadata; } } /** * Gets a valid short number for the specified cost category. * * @param string $regionCode the region for which an example short number is needed * @param int $cost the cost category of number that is needed * @return string a valid short number for the specified region and cost category. Returns an empty string * when the metadata does not contain such information, or the cost is UNKNOWN_COST. */ public function getExampleShortNumberForCost($regionCode, $cost) { $phoneMetadata = $this->getMetadataForRegion($regionCode); if ($phoneMetadata === null) { return ''; } /** @var PhoneNumberDesc $desc */ $desc = null; switch ($cost) { case ShortNumberCost::TOLL_FREE: $desc = $phoneMetadata->getTollFree(); break; case ShortNumberCost::STANDARD_RATE: $desc = $phoneMetadata->getStandardRate(); break; case ShortNumberCost::PREMIUM_RATE: $desc = $phoneMetadata->getPremiumRate(); break; default: // UNKNOWN_COST numbers are computed by the process of elimination from the other cost categories break; } if ($desc !== null && $desc->hasExampleNumber()) { return $desc->getExampleNumber(); } return ''; } /** * Returns true if the given number, exactly as dialed, might be used to connect to an emergency * service in the given region. *

* This method accepts a string, rather than a PhoneNumber, because it needs to distinguish * cases such as "+1 911" and "911", where the former may not connect to an emergency service in * all cases but the latter would. This method takes into account cases where the number might * contain formatting, or might have additional digits appended (when it is okay to do that in * the specified region). * * @param string $number the phone number to test * @param string $regionCode the region where the phone number if being dialled * @return boolean whether the number might be used to connect to an emergency service in the given region */ public function connectsToEmergencyNumber($number, $regionCode) { return $this->matchesEmergencyNumberHelper($number, $regionCode, true /* allows prefix match */); } /** * @param string $number * @param string $regionCode * @param bool $allowPrefixMatch * @return bool */ protected function matchesEmergencyNumberHelper($number, $regionCode, $allowPrefixMatch) { $number = PhoneNumberUtil::extractPossibleNumber($number); $matcher = new Matcher(PhoneNumberUtil::$PLUS_CHARS_PATTERN, $number); if ($matcher->lookingAt()) { // Returns false if the number starts with a plus sign. We don't believe dialing the country // code before emergency numbers (e.g. +1911) works, but later, if that proves to work, we can // add additional logic here to handle it. return false; } $metadata = $this->getMetadataForRegion($regionCode); if ($metadata === null || !$metadata->hasEmergency()) { return false; } $normalizedNumber = PhoneNumberUtil::normalizeDigitsOnly($number); $emergencyDesc = $metadata->getEmergency(); $allowPrefixMatchForRegion = ( $allowPrefixMatch && !in_array($regionCode, static::$regionsWhereEmergencyNumbersMustBeExact) ); return $this->matcherAPI->matchNationalNumber($normalizedNumber, $emergencyDesc, $allowPrefixMatchForRegion); } /** * Given a valid short number, determines whether it is carrier-specific (however, nothing is * implied about its validity). Carrier-specific numbers may connect to a different end-point, or * not connect at all, depending on the user's carrier. If it is important that the number is * valid, then its validity must first be checked using {@link #isValidShortNumber} or * {@link #isValidShortNumberForRegion}. * * @param PhoneNumber $number the valid short number to check * @return boolean whether the short number is carrier-specific, assuming the input was a valid short * number */ public function isCarrierSpecific(PhoneNumber $number) { $regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode()); $regionCode = $this->getRegionCodeForShortNumberFromRegionList($number, $regionCodes); $nationalNumber = $this->getNationalSignificantNumber($number); $phoneMetadata = $this->getMetadataForRegion($regionCode); return ($phoneMetadata !== null) && $this->matchesPossibleNumberAndNationalNumber( $nationalNumber, $phoneMetadata->getCarrierSpecific() ); } /** * Given a valid short number, determines whether it is carrier-specific when dialed from the * given region (however, nothing is implied about its validity). Carrier-specific numbers may * connect to a different end-point, or not connect at all, depending on the user's carrier. If * it is important that the number is valid, then its validity must first be checked using * {@link #isValidShortNumber} or {@link #isValidShortNumberForRegion}. Returns false if the * number doesn't match the region provided. * @param PhoneNumber $number The valid short number to check * @param string $regionDialingFrom The region from which the number is dialed * @return bool Whether the short number is carrier-specific in the provided region, assuming the * input was a valid short number */ public function isCarrierSpecificForRegion(PhoneNumber $number, $regionDialingFrom) { if (!$this->regionDialingFromMatchesNumber($number, $regionDialingFrom)) { return false; } $nationalNumber = $this->getNationalSignificantNumber($number); $phoneMetadata = $this->getMetadataForRegion($regionDialingFrom); return ($phoneMetadata !== null) && $this->matchesPossibleNumberAndNationalNumber($nationalNumber, $phoneMetadata->getCarrierSpecific()); } /** * Given a valid short number, determines whether it is an SMS service (however, nothing is * implied about its validity). An SMS service is where the primary or only intended usage is to * receive and/or send text messages (SMSs). This includes MMS as MMS numbers downgrade to SMS if * the other party isn't MMS-capable. If it is important that the number is valid, then its * validity must first be checked using {@link #isValidShortNumber} or {@link * #isValidShortNumberForRegion}. Returns false if the number doesn't match the region provided. * * @param PhoneNumber $number The valid short number to check * @param string $regionDialingFrom The region from which the number is dialed * @return bool Whether the short number is an SMS service in the provided region, assuming the input * was a valid short number. */ public function isSmsServiceForRegion(PhoneNumber $number, $regionDialingFrom) { if (!$this->regionDialingFromMatchesNumber($number, $regionDialingFrom)) { return false; } $phoneMetadata = $this->getMetadataForRegion($regionDialingFrom); return ($phoneMetadata !== null) && $this->matchesPossibleNumberAndNationalNumber( $this->getNationalSignificantNumber($number), $phoneMetadata->getSmsServices() ); } /** * Helper method to get the region code for a given phone number, from a list of possible region * codes. If the list contains more than one region, the first region for which the number is * valid is returned. * * @param PhoneNumber $number * @param $regionCodes * @return String|null Region Code (or null if none are found) */ protected function getRegionCodeForShortNumberFromRegionList(PhoneNumber $number, $regionCodes) { if (count($regionCodes) == 0) { return null; } if (count($regionCodes) == 1) { return $regionCodes[0]; } $nationalNumber = $this->getNationalSignificantNumber($number); foreach ($regionCodes as $regionCode) { $phoneMetadata = $this->getMetadataForRegion($regionCode); if ($phoneMetadata !== null && $this->matchesPossibleNumberAndNationalNumber($nationalNumber, $phoneMetadata->getShortCode()) ) { // The number is valid for this region. return $regionCode; } } return null; } /** * Check whether a short number is a possible number. If a country calling code is shared by * multiple regions, this returns true if it's possible in any of them. This provides a more * lenient check than {@link #isValidShortNumber}. See {@link * #IsPossibleShortNumberForRegion(PhoneNumber, String)} for details. * * @param $number PhoneNumber the short number to check * @return boolean whether the number is a possible short number */ public function isPossibleShortNumber(PhoneNumber $number) { $regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode()); $shortNumberLength = strlen($this->getNationalSignificantNumber($number)); foreach ($regionCodes as $region) { $phoneMetadata = $this->getMetadataForRegion($region); if ($phoneMetadata === null) { continue; } if (in_array($shortNumberLength, $phoneMetadata->getGeneralDesc()->getPossibleLength())) { return true; } } return false; } /** * Check whether a short number is a possible number when dialled from a region, given the number * in the form of a string, and the region where the number is dialled from. This provides a more * lenient check than {@link #isValidShortNumber}. * * @param PhoneNumber $shortNumber The short number to check * @param string $regionDialingFrom Region dialing From * @return boolean whether the number is a possible short number */ public function isPossibleShortNumberForRegion(PhoneNumber $shortNumber, $regionDialingFrom) { if (!$this->regionDialingFromMatchesNumber($shortNumber, $regionDialingFrom)) { return false; } $phoneMetadata = $this->getMetadataForRegion($regionDialingFrom); if ($phoneMetadata === null) { return false; } $numberLength = strlen($this->getNationalSignificantNumber($shortNumber)); return in_array($numberLength, $phoneMetadata->getGeneralDesc()->getPossibleLength()); } /** * Tests whether a short number matches a valid pattern. If a country calling code is shared by * multiple regions, this returns true if it's valid in any of them. Note that this doesn't verify * the number is actually in use, which is impossible to tell by just looking at the number * itself. See {@link #isValidShortNumberForRegion(PhoneNumber, String)} for details. * * @param $number PhoneNumber the short number for which we want to test the validity * @return boolean whether the short number matches a valid pattern */ public function isValidShortNumber(PhoneNumber $number) { $regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode()); $regionCode = $this->getRegionCodeForShortNumberFromRegionList($number, $regionCodes); if (count($regionCodes) > 1 && $regionCode !== null) { // If a matching region had been found for the phone number from among two or more regions, // then we have already implicitly verified its validity for that region. return true; } return $this->isValidShortNumberForRegion($number, $regionCode); } /** * Tests whether a short number matches a valid pattern in a region. Note that this doesn't verify * the number is actually in use, which is impossible to tell by just looking at the number * itself. * * @param PhoneNumber $number The Short number for which we want to test the validity * @param string $regionDialingFrom the region from which the number is dialed * @return boolean whether the short number matches a valid pattern */ public function isValidShortNumberForRegion(PhoneNumber $number, $regionDialingFrom) { if (!$this->regionDialingFromMatchesNumber($number, $regionDialingFrom)) { return false; } $phoneMetadata = $this->getMetadataForRegion($regionDialingFrom); if ($phoneMetadata === null) { return false; } $shortNumber = $this->getNationalSignificantNumber($number); $generalDesc = $phoneMetadata->getGeneralDesc(); if (!$this->matchesPossibleNumberAndNationalNumber($shortNumber, $generalDesc)) { return false; } $shortNumberDesc = $phoneMetadata->getShortCode(); return $this->matchesPossibleNumberAndNationalNumber($shortNumber, $shortNumberDesc); } /** * Gets the expected cost category of a short number when dialled from a region (however, nothing is * implied about its validity). If it is important that the number is valid, then its validity * must first be checked using {@link isValidShortNumberForRegion}. Note that emergency numbers * are always considered toll-free. * Example usage: *

{@code
     * $shortInfo = ShortNumberInfo::getInstance();
     * $shortNumber = PhoneNumberUtil::parse("110", "US);
     * $regionCode = "FR";
     * if ($shortInfo->isValidShortNumberForRegion($shortNumber, $regionCode)) {
     *     $cost = $shortInfo->getExpectedCostForRegion($shortNumber, $regionCode);
     *    // Do something with the cost information here.
     * }}
* * @param PhoneNumber $number the short number for which we want to know the expected cost category, * as a string * @param string $regionDialingFrom the region from which the number is dialed * @return int the expected cost category for that region of the short number. Returns UNKNOWN_COST if * the number does not match a cost category. Note that an invalid number may match any cost * category. */ public function getExpectedCostForRegion(PhoneNumber $number, $regionDialingFrom) { if (!$this->regionDialingFromMatchesNumber($number, $regionDialingFrom)) { return ShortNumberCost::UNKNOWN_COST; } // Note that regionDialingFrom may be null, in which case phoneMetadata will also be null. $phoneMetadata = $this->getMetadataForRegion($regionDialingFrom); if ($phoneMetadata === null) { return ShortNumberCost::UNKNOWN_COST; } $shortNumber = $this->getNationalSignificantNumber($number); // The possible lengths are not present for a particular sub-type if they match the general // description; for this reason, we check the possible lengths against the general description // first to allow an early exit if possible. if (!in_array(strlen($shortNumber), $phoneMetadata->getGeneralDesc()->getPossibleLength())) { return ShortNumberCost::UNKNOWN_COST; } // The cost categories are tested in order of decreasing expense, since if for some reason the // patterns overlap the most expensive matching cost category should be returned. if ($this->matchesPossibleNumberAndNationalNumber($shortNumber, $phoneMetadata->getPremiumRate())) { return ShortNumberCost::PREMIUM_RATE; } if ($this->matchesPossibleNumberAndNationalNumber($shortNumber, $phoneMetadata->getStandardRate())) { return ShortNumberCost::STANDARD_RATE; } if ($this->matchesPossibleNumberAndNationalNumber($shortNumber, $phoneMetadata->getTollFree())) { return ShortNumberCost::TOLL_FREE; } if ($this->isEmergencyNumber($shortNumber, $regionDialingFrom)) { // Emergency numbers are implicitly toll-free. return ShortNumberCost::TOLL_FREE; } return ShortNumberCost::UNKNOWN_COST; } /** * Gets the expected cost category of a short number (however, nothing is implied about its * validity). If the country calling code is unique to a region, this method behaves exactly the * same as {@link #getExpectedCostForRegion(PhoneNumber, String)}. However, if the country calling * code is shared by multiple regions, then it returns the highest cost in the sequence * PREMIUM_RATE, UNKNOWN_COST, STANDARD_RATE, TOLL_FREE. The reason for the position of * UNKNOWN_COST in this order is that if a number is UNKNOWN_COST in one region but STANDARD_RATE * or TOLL_FREE in another, its expected cost cannot be estimated as one of the latter since it * might be a PREMIUM_RATE number. * *

* For example, if a number is STANDARD_RATE in the US, but TOLL_FREE in Canada, the expected * cost returned by this method will be STANDARD_RATE, since the NANPA countries share the same * country calling code. *

* * Note: If the region from which the number is dialed is known, it is highly preferable to call * {@link #getExpectedCostForRegion(PhoneNumber, String)} instead. * * @param PhoneNumber $number the short number for which we want to know the expected cost category * @return int the highest expected cost category of the short number in the region(s) with the given * country calling code */ public function getExpectedCost(PhoneNumber $number) { $regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode()); if (count($regionCodes) == 0) { return ShortNumberCost::UNKNOWN_COST; } if (count($regionCodes) == 1) { return $this->getExpectedCostForRegion($number, $regionCodes[0]); } $cost = ShortNumberCost::TOLL_FREE; foreach ($regionCodes as $regionCode) { $costForRegion = $this->getExpectedCostForRegion($number, $regionCode); switch ($costForRegion) { case ShortNumberCost::PREMIUM_RATE: return ShortNumberCost::PREMIUM_RATE; case ShortNumberCost::UNKNOWN_COST: $cost = ShortNumberCost::UNKNOWN_COST; break; case ShortNumberCost::STANDARD_RATE: if ($cost != ShortNumberCost::UNKNOWN_COST) { $cost = ShortNumberCost::STANDARD_RATE; } break; case ShortNumberCost::TOLL_FREE: // Do nothing break; } } return $cost; } /** * Returns true if the given number exactly matches an emergency service number in the given * region. *

* This method takes into account cases where the number might contain formatting, but doesn't * allow additional digits to be appended. Note that {@code isEmergencyNumber(number, region)} * implies {@code connectsToEmergencyNumber(number, region)}. * * @param string $number the phone number to test * @param string $regionCode the region where the phone number is being dialled * @return boolean whether the number exactly matches an emergency services number in the given region */ public function isEmergencyNumber($number, $regionCode) { return $this->matchesEmergencyNumberHelper($number, $regionCode, false /* doesn't allow prefix match */); } /** * Gets the national significant number of the a phone number. Note a national significant number * doesn't contain a national prefix or any formatting. *

* This is a temporary duplicate of the {@code getNationalSignificantNumber} method from * {@code PhoneNumberUtil}. Ultimately a canonical static version should exist in a separate * utility class (to prevent {@code ShortNumberInfo} needing to depend on PhoneNumberUtil). * * @param PhoneNumber $number the phone number for which the national significant number is needed * @return string the national significant number of the PhoneNumber object passed in */ protected function getNationalSignificantNumber(PhoneNumber $number) { // If leading zero(s) have been set, we prefix this now. Note this is not a national prefix. $nationalNumber = ''; if ($number->isItalianLeadingZero()) { $zeros = str_repeat('0', $number->getNumberOfLeadingZeros()); $nationalNumber .= $zeros; } $nationalNumber .= $number->getNationalNumber(); return $nationalNumber; } /** * TODO: Once we have benchmarked ShortnumberInfo, consider if it is worth keeping * this performance optimization. * @param string $number * @param PhoneNumberDesc $numberDesc * @return bool */ protected function matchesPossibleNumberAndNationalNumber($number, PhoneNumberDesc $numberDesc) { if (count($numberDesc->getPossibleLength()) > 0 && !in_array(strlen($number), $numberDesc->getPossibleLength())) { return false; } return $this->matcherAPI->matchNationalNumber($number, $numberDesc, false); } }