AutorÃa | Ultima modificación | Ver Log |
<?php
/**
* Methods for getting information about short phone numbers, such as short codes and emergency
* numbers. Note that most commercial short numbers are not handled here, but by the
* {@link PhoneNumberUtil}.
*
* @author Shaopeng Jia
* @author David Yonge-Mallo
* @since 5.8
*/
namespace libphonenumber;
class ShortNumberInfo
{
protected static ?ShortNumberInfo $instance;
protected MatcherAPIInterface $matcherAPI;
protected string $currentFilePrefix;
/**
* @var array<string,PhoneMetadata>
*/
protected array $regionToMetadataMap = [];
/**
* @var array<int,string[]>
*/
protected array $countryCallingCodeToRegionCodeMap = [];
/**
* @var array<int,PhoneMetadata>
*/
protected array $countryCodeToNonGeographicalMetadataMap = [];
protected const REGIONS_WHERE_EMERGENCY_NUMBERS_MUST_BE_EXACT = [
'BR',
'CL',
'NI',
];
protected function __construct(MatcherAPIInterface $matcherAPI)
{
$this->matcherAPI = $matcherAPI;
// TODO: Create ShortNumberInfo for a given map
$this->countryCallingCodeToRegionCodeMap = CountryCodeToRegionCodeMap::COUNTRY_CODE_TO_REGION_CODE_MAP;
$this->currentFilePrefix = __DIR__ . '/data/ShortNumberMetadata';
// Initialise PhoneNumberUtil to make sure regex's are setup correctly
PhoneNumberUtil::getInstance();
}
public static function getInstance(): ShortNumberInfo
{
if (!isset(static::$instance)) {
static::$instance = new self(RegexBasedMatcher::create());
}
return static::$instance;
}
public static function resetInstance(): void
{
static::$instance = null;
}
/**
* Returns a list with the 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.
*
* @return string[]
*/
protected function getRegionCodesForCountryCode(int $countryCallingCode): array
{
return $this->countryCallingCodeToRegionCodeMap[$countryCallingCode] ?? [];
}
/**
* Helper method to check that the country calling code of the number matches the region it's
* being dialed from.
*/
protected function regionDialingFromMatchesNumber(PhoneNumber $number, string $regionDialingFrom): bool
{
if (trim($regionDialingFrom) === '') {
return false;
}
$regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode());
return in_array(strtoupper($regionDialingFrom), $regionCodes);
}
/**
* @return string[]
*/
public function getSupportedRegions(): array
{
return ShortNumbersRegionCodeSet::SHORT_NUMBERS_REGION_CODE_SET;
}
/**
* 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(string $regionCode): string
{
$phoneMetadata = $this->getMetadataForRegion($regionCode);
if ($phoneMetadata === null) {
return '';
}
/** @var PhoneNumberDesc $desc */
$desc = $phoneMetadata->getShortCode();
if ($desc !== null && $desc->hasExampleNumber()) {
return $desc->getExampleNumber();
}
return '';
}
public function getMetadataForRegion(string $regionCode): ?PhoneMetadata
{
$regionCode = strtoupper($regionCode);
if (!in_array($regionCode, ShortNumbersRegionCodeSet::SHORT_NUMBERS_REGION_CODE_SET)) {
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 $this->regionToMetadataMap[$regionCode] ?? null;
}
protected function loadMetadataFromFile(string $filePrefix, string $regionCode, int $countryCallingCode): void
{
$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 ShortNumberCost 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(string $regionCode, int $cost): string
{
$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.
* <p>
* 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(string $number, string $regionCode): bool
{
return $this->matchesEmergencyNumberHelper($number, $regionCode, true /* allows prefix match */);
}
/**
*/
protected function matchesEmergencyNumberHelper(string $number, string $regionCode, bool $allowPrefixMatch): bool
{
$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(strtoupper($regionCode), static::REGIONS_WHERE_EMERGENCY_NUMBERS_MUST_BE_EXACT)
);
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): bool
{
$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, string $regionDialingFrom): bool
{
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, string $regionDialingFrom): bool
{
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 string[] $regionCodes
* @return string|null Region Code (or null if none are found)
*/
protected function getRegionCodeForShortNumberFromRegionList(PhoneNumber $number, array $regionCodes): ?string
{
if ($regionCodes === []) {
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): bool
{
$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, string $regionDialingFrom): bool
{
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): bool
{
$regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode());
$regionCode = $this->getRegionCodeForShortNumberFromRegionList($number, $regionCodes);
if ($regionCode === null) {
return false;
}
if (count($regionCodes) > 1) {
// 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, string $regionDialingFrom): bool
{
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:
* <pre>{@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.
* }}</pre>
*
* @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.
* @see ShortNumberCost
*/
public function getExpectedCostForRegion(PhoneNumber $number, string $regionDialingFrom): int
{
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.
*
* <p>
* 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.
* </p>
*
* 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
* @see ShortNumberCost
*/
public function getExpectedCost(PhoneNumber $number): int
{
$regionCodes = $this->getRegionCodesForCountryCode($number->getCountryCode());
if ($regionCodes === []) {
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.
* <p>
* 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(string $number, string $regionCode): bool
{
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.
* <p>
* 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): string
{
// 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.
*/
protected function matchesPossibleNumberAndNationalNumber(string $number, PhoneNumberDesc $numberDesc): bool
{
if (count($numberDesc->getPossibleLength()) > 0 && !in_array(strlen($number), $numberDesc->getPossibleLength())) {
return false;
}
return $this->matcherAPI->matchNationalNumber($number, $numberDesc, false);
}
}