diff --git a/.github/workflows/build_and_run_unit_tests_linux.yml b/.github/workflows/build_and_run_unit_tests_linux.yml index f69a6ae54..c3e6fe88e 100644 --- a/.github/workflows/build_and_run_unit_tests_linux.yml +++ b/.github/workflows/build_and_run_unit_tests_linux.yml @@ -19,6 +19,7 @@ jobs: run: | (cd resources/geocoding; zip -r ../../resources/geocoding.zip *) (cd resources/test/geocoding; zip -r ../../../resources/test/testgeocoding.zip *) + (cd resources/test/carrier; zip -r ../../../resources/test/testcarrier.zip *) - name: Restore dependencies run: dotnet restore working-directory: ./csharp diff --git a/.github/workflows/run_all_tests_and_upload_code_coverage.yml b/.github/workflows/run_all_tests_and_upload_code_coverage.yml index 34e5692f1..1fc1df20d 100644 --- a/.github/workflows/run_all_tests_and_upload_code_coverage.yml +++ b/.github/workflows/run_all_tests_and_upload_code_coverage.yml @@ -20,6 +20,7 @@ jobs: run: | Compress-Archive -Path "resources\geocoding\*" -DestinationPath "resources\geocoding.zip" Compress-Archive -Path "resources\test\geocoding\*" -DestinationPath "resources\test\testgeocoding.zip" + Compress-Archive -Path "resources\test\carrier\*" -DestinationPath "resources\test\testcarrier.zip" - name: Run tests run: dotnet test csharp/PhoneNumbers.sln --configuration Release --verbosity normal --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Upload coverage reports to Codecov diff --git a/.gitignore b/.gitignore index a15e51bef..98782a0d0 100644 --- a/.gitignore +++ b/.gitignore @@ -202,6 +202,8 @@ FakesAssemblies/ # Zipped Geocoding Data geocoding.zip testgeocoding.zip +/resources/carrier.zip +/resources/test/testcarrier.zip # Performance tests artifacts csharp/PhoneNumbers.PerformanceTest/BenchmarkDotNet.Artifacts/ diff --git a/README.md b/README.md index 2bc94c420..a35fd2fad 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,20 @@ var regionCode = phoneNumberUtil.GetRegionCodeForNumber(phoneNumber); Console.WriteLine(regionCode); // US ``` +### Get the carrier name for a phone number +```csharp +using PhoneNumbers; + +var phoneNumberUtil = PhoneNumberUtil.GetInstance(); +var carrierMapper = PhoneNumberToCarrierMapper.GetInstance(); +var phoneNumber = phoneNumberUtil.Parse("+917503397672", null); +var carrierName = carrierMapper.GetNameForNumber(phoneNumber, Locale.English); + +Console.WriteLine(carrierName); // Vodafone +``` + +> **Note:** Carrier data reflects the original network allocation. If the country supports mobile number portability, the number may have since moved to a different carrier. Use `GetSafeDisplayName` to return an empty string in those regions. + ## Features * Parsing/formatting/validating phone numbers for all countries/regions of the world. @@ -94,6 +108,7 @@ Console.WriteLine(regionCode); // US * IsPossibleNumber - quickly guessing whether a number is a possible phone number by using only the length information, much faster than a full validation. * AsYouTypeFormatter - formats phone numbers on-the-fly when users enter each digit. * FindNumbers - finds numbers in text input +* PhoneNumberToCarrierMapper - looks up the carrier name originally assigned to a mobile or pager number, with locale-aware output and a safe-display mode for regions with mobile number portability. See [PhoneNumberUtil.cs](csharp/PhoneNumbers/PhoneNumberUtil.cs) for the various methods and properties available. @@ -119,21 +134,31 @@ For more information on metadata usage, please refer to the [main repository faq ## Running tests locally -To run tests locally, you will need a zip version of the `geocoding.zip` file stored in the `resources` folder -and `testgeocoding.zip` file stored in the `resources/test` folder. +To run tests locally, you will need zip versions of the geocoding and carrier data files. + +| Zip file | Source directory | +|---|---| +| `resources/geocoding.zip` | `resources/geocoding/` | +| `resources/carrier.zip` | `resources/carrier/` | +| `resources/test/testgeocoding.zip` | `resources/test/geocoding/` | +| `resources/test/testcarrier.zip` | `resources/test/carrier/` | -On linux, you can run the following commands to generate the zip accordingly +On linux, you can run the following commands to generate the zips accordingly ```bash (cd resources/geocoding; zip -r ../../resources/geocoding.zip *) +(cd resources/carrier; zip -r ../../resources/carrier.zip *) (cd resources/test/geocoding; zip -r ../../../resources/test/testgeocoding.zip *) +(cd resources/test/carrier; zip -r ../../../resources/test/testcarrier.zip *) ``` For windows, you can use the following powershell script ```powershell Compress-Archive -Path "resources\geocoding\*" -DestinationPath "resources\geocoding.zip" +Compress-Archive -Path "resources\carrier\*" -DestinationPath "resources\carrier.zip" Compress-Archive -Path "resources\test\geocoding\*" -DestinationPath "resources\test\testgeocoding.zip" +Compress-Archive -Path "resources\test\carrier\*" -DestinationPath "resources\test\testcarrier.zip" ``` ## Contributing diff --git a/appveyor.yml b/appveyor.yml index 93d7e4b33..5f772190c 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -25,7 +25,9 @@ branches: before_build: - dotnet restore csharp -s https://api.nuget.org/v3/index.json - ps: Compress-Archive -Path "resources\geocoding\*" -DestinationPath "resources\geocoding.zip" + - ps: Compress-Archive -Path "resources\carrier\*" -DestinationPath "resources\carrier.zip" - ps: Compress-Archive -Path "resources\test\geocoding\*" -DestinationPath "resources\test\testgeocoding.zip" + - ps: Compress-Archive -Path "resources\test\carrier\*" -DestinationPath "resources\test\testcarrier.zip" build_script: - dotnet pack -c Release csharp\PhoneNumbers - dotnet pack -c Release csharp\PhoneNumbers.Extensions diff --git a/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj b/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj index 64f08ce01..8a6348861 100644 --- a/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj +++ b/csharp/PhoneNumbers.Test/PhoneNumbers.Test.csproj @@ -33,8 +33,10 @@ + + diff --git a/csharp/PhoneNumbers.Test/TestPhoneNumberToCarrierMapper.cs b/csharp/PhoneNumbers.Test/TestPhoneNumberToCarrierMapper.cs new file mode 100644 index 000000000..660925883 --- /dev/null +++ b/csharp/PhoneNumbers.Test/TestPhoneNumberToCarrierMapper.cs @@ -0,0 +1,358 @@ +/* + * Copyright (C) 2013 The Libphonenumber Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System.Reflection; +using Xunit; + +namespace PhoneNumbers.Test +{ + /// + /// Mirrors TestPhoneNumberToCarrierMapper's synthetic-data tests using testcarrier.zip + /// to exercise the zip code path (LoadFileNamesFromZip + GetManifestZipFileStream). + /// + [Collection("TestZippedCarrierTestCase")] + public class TestZippedPhoneNumberToCarrierMapper + { + private static readonly PhoneNumberToCarrierMapper s_zippedMapper = + new PhoneNumberToCarrierMapper("testcarrier.", Assembly.GetExecutingAssembly()); + + // Same numbers as the testCarrierMapper tests in TestPhoneNumberToCarrierMapper. + private static readonly PhoneNumber s_ukMobile1Test = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7387654321L).Build(); + private static readonly PhoneNumber s_ukFixed1Test = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(1123456789L).Build(); + private static readonly PhoneNumber s_ukPager = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7601234567L).Build(); + private static readonly PhoneNumber s_ukInvalidTest = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7301234L).Build(); + private static readonly PhoneNumber s_aoMobile1Test = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(917654321L).Build(); + + [Fact] + public void TestGetNameForMobilePortableRegion() + { + Assert.Equal("British carrier", s_zippedMapper.GetNameForNumber(s_ukMobile1Test, Locale.English)); + Assert.Equal("British carrier", s_zippedMapper.GetNameForNumber(s_ukMobile1Test, Locale.French)); + Assert.Equal("", s_zippedMapper.GetSafeDisplayName(s_ukMobile1Test, Locale.English)); + } + + [Fact] + public void TestGetNameForNonMobilePortableRegion() + { + Assert.Equal("Angolan carrier", s_zippedMapper.GetNameForNumber(s_aoMobile1Test, Locale.English)); + Assert.Equal("Angolan carrier", s_zippedMapper.GetSafeDisplayName(s_aoMobile1Test, Locale.English)); + } + + [Fact] + public void TestGetNameForPagerNumber() + { + Assert.Equal("British pager", s_zippedMapper.GetNameForNumber(s_ukPager, Locale.English)); + } + + [Fact] + public void TestGetNameForFixedLineNumber() + { + Assert.Equal("", s_zippedMapper.GetNameForNumber(s_ukFixed1Test, Locale.English)); + Assert.Equal("British fixed line carrier", s_zippedMapper.GetNameForValidNumber(s_ukFixed1Test, Locale.English)); + } + + [Fact] + public void TestGetNameForInvalidNumber() + { + Assert.Equal("", s_zippedMapper.GetNameForNumber(s_ukInvalidTest, Locale.English)); + } + + [Fact] + public void TestGetNameWithSwedishLocale() + { + Assert.Equal("Brittisk operatör", s_zippedMapper.GetNameForNumber(s_ukMobile1Test, new Locale("sv", "SE"))); + } + } + + [Collection("TestMetadataTestCase")] + public class TestPhoneNumberToCarrierMapper + { + private readonly PhoneNumberToCarrierMapper carrierMapper = PhoneNumberToCarrierMapper.GetInstance(); + + // Test-data mapper backed by resources/test/carrier/ + private static readonly PhoneNumberToCarrierMapper testCarrierMapper = + new PhoneNumberToCarrierMapper("carrier.", Assembly.GetExecutingAssembly()); + + // ── Production-data numbers (used with carrierMapper / GetInstance()) ───────────────── + + // UK mobile: 447106 -> O2, 447306 -> Virgin Mobile (en/44.txt) + private static readonly PhoneNumber UK_MOBILE1 = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7106123456L).Build(); + private static readonly PhoneNumber UK_MOBILE2 = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7306123456L).Build(); + private static readonly PhoneNumber UK_FIXED1 = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(2071234567L).Build(); + // Too short to be valid — UNKNOWN type + private static readonly PhoneNumber UK_INVALID_NUMBER = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7301234L).Build(); + + // Angola mobile: 24491 -> Movicel, 24492 -> UNITEL (en/244.txt) + private static readonly PhoneNumber AO_MOBILE1 = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(912345678L).Build(); + private static readonly PhoneNumber AO_MOBILE2 = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(923456789L).Build(); + private static readonly PhoneNumber AO_FIXED1 = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(222333444L).Build(); + // Too short to be valid — UNKNOWN type + private static readonly PhoneNumber AO_INVALID_NUMBER = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(123456L).Build(); + // Prefix 24498 is not present in en/244.txt + private static readonly PhoneNumber AO_NUMBER_WITH_MISSING_PREFIX = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(985000000L).Build(); + + // US FIXED_LINE_OR_MOBILE — no carrier data for this prefix in en/1.txt + private static readonly PhoneNumber US_FIXED_OR_MOBILE = + new PhoneNumber.Builder().SetCountryCode(1).SetNationalNumber(6502530000L).Build(); + + // No carrier data files exist for these country codes + private static readonly PhoneNumber NUMBER_WITH_INVALID_COUNTRY_CODE = + new PhoneNumber.Builder().SetCountryCode(999).SetNationalNumber(2423651234L).Build(); + private static readonly PhoneNumber INTERNATIONAL_TOLL_FREE = + new PhoneNumber.Builder().SetCountryCode(800).SetNationalNumber(12345678L).Build(); + + // ── Test-data numbers (used with testCarrierMapper, match prefixes in resources/test/carrier/) ─ + + // en/44.txt: 4411 -> "British fixed line carrier", 4473 -> "British carrier", 44760 -> "British pager" + // sv/44.txt: 4473 -> "Brittisk operatör" + private static readonly PhoneNumber UK_MOBILE1_TEST = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7387654321L).Build(); + private static readonly PhoneNumber UK_MOBILE2_TEST = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7487654321L).Build(); + private static readonly PhoneNumber UK_FIXED1_TEST = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(1123456789L).Build(); + private static readonly PhoneNumber UK_FIXED2_TEST = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(2987654321L).Build(); + private static readonly PhoneNumber UK_PAGER = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7601234567L).Build(); + private static readonly PhoneNumber UK_INVALID_TEST = + new PhoneNumber.Builder().SetCountryCode(44).SetNationalNumber(7301234L).Build(); + + // en/244.txt: 244917 -> "Angolan carrier", 244262 -> "Angolan fixed line carrier" + private static readonly PhoneNumber AO_MOBILE1_TEST = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(917654321L).Build(); + private static readonly PhoneNumber AO_MOBILE2_TEST = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(927654321L).Build(); + private static readonly PhoneNumber AO_FIXED1_TEST = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(22254321L).Build(); + private static readonly PhoneNumber AO_FIXED2_TEST = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(26254321L).Build(); + private static readonly PhoneNumber AO_INVALID_TEST = + new PhoneNumber.Builder().SetCountryCode(244).SetNationalNumber(101234L).Build(); + + // en/1650.txt: 1650212 -> "US carrier", 1650213 -> "US carrier2" + // NANPA data is split by area code prefix (e.g. 1650), so we need a file per prefix. + private static readonly PhoneNumber US_FIXED_OR_MOBILE_TEST = + new PhoneNumber.Builder().SetCountryCode(1).SetNationalNumber(6502123456L).Build(); + // Area code 212 (New York) — no split file exists in the test data, so lookup returns "". + private static readonly PhoneNumber s_usNanpaNoDataTest = + new PhoneNumber.Builder().SetCountryCode(1).SetNationalNumber(2128120000L).Build(); + + [Fact] + public void TestGetNameForMobilePortableRegion() + { + // UK supports mobile number portability: GetNameForNumber still returns the carrier. + Assert.Equal("O2", carrierMapper.GetNameForNumber(UK_MOBILE1, Locale.English)); + // No French carrier data for UK — falls back to English. + Assert.Equal("O2", carrierMapper.GetNameForNumber(UK_MOBILE1, Locale.French)); + // GetSafeDisplayName returns empty because UK has MNP. + Assert.Equal("", carrierMapper.GetSafeDisplayName(UK_MOBILE1, Locale.English)); + } + + [Fact] + public void TestGetNameForNonMobilePortableRegion() + { + // Angola does not support MNP: both methods return the carrier. + Assert.Equal("Movicel", carrierMapper.GetNameForNumber(AO_MOBILE1, Locale.English)); + Assert.Equal("Movicel", carrierMapper.GetSafeDisplayName(AO_MOBILE1, Locale.English)); + } + + [Fact] + public void TestGetNameForFixedLineNumber() + { + // Fixed-line numbers are not mobile type: GetNameForNumber returns "". + Assert.Equal("", carrierMapper.GetNameForNumber(AO_FIXED1, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForNumber(UK_FIXED1, Locale.English)); + // GetNameForValidNumber skips the type check but there is no carrier data for fixed lines. + Assert.Equal("", carrierMapper.GetNameForValidNumber(AO_FIXED1, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForValidNumber(UK_FIXED1, Locale.English)); + } + + [Fact] + public void TestGetNameForFixedOrMobileNumber() + { + // FIXED_LINE_OR_MOBILE is treated as mobile by GetNameForNumber. + // No carrier data exists for US prefix 1-6502..., so "" is returned. + Assert.Equal("", carrierMapper.GetNameForNumber(US_FIXED_OR_MOBILE, Locale.English)); + } + + [Fact] + public void TestGetNameForNumberWithNoDataFile() + { + // No carrier data file for country code 999 or 800. + Assert.Equal("", carrierMapper.GetNameForNumber(NUMBER_WITH_INVALID_COUNTRY_CODE, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForNumber(INTERNATIONAL_TOLL_FREE, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForValidNumber(NUMBER_WITH_INVALID_COUNTRY_CODE, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForValidNumber(INTERNATIONAL_TOLL_FREE, Locale.English)); + } + + [Fact] + public void TestGetNameForNumberWithMissingPrefix() + { + // Prefix 24498 is absent from en/244.txt — returns "" regardless of number type. + Assert.Equal("", carrierMapper.GetNameForNumber(AO_NUMBER_WITH_MISSING_PREFIX, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForValidNumber(AO_NUMBER_WITH_MISSING_PREFIX, Locale.English)); + } + + [Fact] + public void TestGetNameForInvalidNumber() + { + // UNKNOWN-type numbers are not mobile, so GetNameForNumber returns "". + Assert.Equal("", carrierMapper.GetNameForNumber(UK_INVALID_NUMBER, Locale.English)); + Assert.Equal("", carrierMapper.GetNameForNumber(AO_INVALID_NUMBER, Locale.English)); + } + + [Fact] + public void TestGetNameForValidNumber() + { + // GetNameForValidNumber skips type checking and returns the carrier directly. + Assert.Equal("O2", carrierMapper.GetNameForValidNumber(UK_MOBILE1, Locale.English)); + Assert.Equal("Virgin Mobile", carrierMapper.GetNameForValidNumber(UK_MOBILE2, Locale.English)); + Assert.Equal("Movicel", carrierMapper.GetNameForValidNumber(AO_MOBILE1, Locale.English)); + Assert.Equal("UNITEL", carrierMapper.GetNameForValidNumber(AO_MOBILE2, Locale.English)); + } + + [Fact] + public void TestGetSafeDisplayName() + { + // UK supports MNP — always returns "". + Assert.Equal("", carrierMapper.GetSafeDisplayName(UK_MOBILE1, Locale.English)); + // Angola does not support MNP — returns the carrier name. + Assert.Equal("Movicel", carrierMapper.GetSafeDisplayName(AO_MOBILE1, Locale.English)); + // Fixed-line: GetSafeDisplayName calls GetNameForNumber which returns "" for non-mobile type. + Assert.Equal("", carrierMapper.GetSafeDisplayName(UK_FIXED1, Locale.English)); + } + + [Fact] + public void TestGetNameFallbackToEnglish() + { + // French has no carrier data for UK, so the result falls back to English. + Assert.Equal("O2", carrierMapper.GetNameForValidNumber(UK_MOBILE1, Locale.French)); + // Korean never falls back to English (zh, ja, ko are excluded). + Assert.Equal("", carrierMapper.GetNameForValidNumber(UK_MOBILE1, Locale.Korean)); + } + + // ── Tests using synthetic test carrier data ───────────────────────────────────────────── + + [Fact] + public void TestGetNameForMobilePortableRegion_WithTestData() + { + // UK has MNP: GetNameForNumber still resolves the original carrier. + Assert.Equal("British carrier", testCarrierMapper.GetNameForNumber(UK_MOBILE1_TEST, Locale.English)); + // French has no test data for UK — falls back to English. + Assert.Equal("British carrier", testCarrierMapper.GetNameForNumber(UK_MOBILE1_TEST, Locale.French)); + // GetSafeDisplayName returns "" because UK supports MNP. + Assert.Equal("", testCarrierMapper.GetSafeDisplayName(UK_MOBILE1_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForNonMobilePortableRegion_WithTestData() + { + // Angola has no MNP: both methods return the carrier. + Assert.Equal("Angolan carrier", testCarrierMapper.GetNameForNumber(AO_MOBILE1_TEST, Locale.English)); + Assert.Equal("Angolan carrier", testCarrierMapper.GetSafeDisplayName(AO_MOBILE1_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForPagerNumber() + { + // PAGER is treated as mobile by GetNameForNumber — carrier data is returned. + Assert.Equal("British pager", testCarrierMapper.GetNameForNumber(UK_PAGER, Locale.English)); + } + + [Fact] + public void TestGetNameForFixedOrMobileNumber_WithCarrierData() + { + // FIXED_LINE_OR_MOBILE is treated as mobile, so carrier data is returned. + Assert.Equal("US carrier", testCarrierMapper.GetNameForNumber(US_FIXED_OR_MOBILE_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForFixedLineNumber_WithTestData() + { + // Fixed-line type: GetNameForNumber skips non-mobile types — returns "". + Assert.Equal("", testCarrierMapper.GetNameForNumber(AO_FIXED1_TEST, Locale.English)); + Assert.Equal("", testCarrierMapper.GetNameForNumber(UK_FIXED1_TEST, Locale.English)); + // GetNameForValidNumber bypasses the type check — returns the carrier from the data file. + Assert.Equal("Angolan fixed line carrier", testCarrierMapper.GetNameForValidNumber(AO_FIXED2_TEST, Locale.English)); + Assert.Equal("British fixed line carrier", testCarrierMapper.GetNameForValidNumber(UK_FIXED1_TEST, Locale.English)); + // No carrier data for this UK fixed-line prefix — returns "" even without type check. + Assert.Equal("", testCarrierMapper.GetNameForValidNumber(UK_FIXED2_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForNumberWithMissingPrefix_WithTestData() + { + // Prefixes not in the test data return "" regardless of number type. + Assert.Equal("", testCarrierMapper.GetNameForNumber(UK_MOBILE2_TEST, Locale.English)); + Assert.Equal("", testCarrierMapper.GetNameForNumber(AO_MOBILE2_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForInvalidNumber_WithTestData() + { + Assert.Equal("", testCarrierMapper.GetNameForNumber(UK_INVALID_TEST, Locale.English)); + Assert.Equal("", testCarrierMapper.GetNameForNumber(AO_INVALID_TEST, Locale.English)); + } + + [Fact] + public void TestGetNameForNumberWithSwedishLocale() + { + // Swedish test data (sv/44.txt) has an entry for prefix 4473. + Assert.Equal("Brittisk operatör", + testCarrierMapper.GetNameForNumber(UK_MOBILE1_TEST, new Locale("sv", "SE"))); + } + + // ── NANPA data-split tests ────────────────────────────────────────────────────────────── + // As the NANPA carrier data is split into per-area-code files (e.g. en/1650.txt), + // GetDescriptionForNumber must use phonePrefix = 1000 + (nationalNumber / 10_000_000) + // for country code 1 instead of the country code itself. + + [Fact] + public void TestGetNameForNanpaNumberWithSplitData() + { + // 6502123456 → prefix 1650 → en/1650.txt → "US carrier" + Assert.Equal("US carrier", + testCarrierMapper.GetNameForNumber(US_FIXED_OR_MOBILE_TEST, Locale.English)); + // 6502133456 → prefix 1650 → en/1650.txt → "US carrier2" + Assert.Equal("US carrier2", + testCarrierMapper.GetNameForValidNumber( + new PhoneNumber.Builder().SetCountryCode(1).SetNationalNumber(6502133456L).Build(), + Locale.English)); + } + + [Fact] + public void TestGetNameForNanpaNumberWithNoSplitFile() + { + // Area code 212 has no en/1212.txt split file — returns "". + Assert.Equal("", testCarrierMapper.GetNameForValidNumber(s_usNanpaNoDataTest, Locale.English)); + } + } +} diff --git a/csharp/PhoneNumbers/PhoneNumberToCarrierMapper.cs b/csharp/PhoneNumbers/PhoneNumberToCarrierMapper.cs new file mode 100644 index 000000000..9e394cd7a --- /dev/null +++ b/csharp/PhoneNumbers/PhoneNumberToCarrierMapper.cs @@ -0,0 +1,110 @@ +#nullable disable +/* + * Copyright (C) 2013 The Libphonenumber Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Reflection; + +namespace PhoneNumbers +{ + /// + /// A phone prefix mapper which provides carrier information related to a phone number. + /// + /// Carrier data is the one the number was originally allocated to. If the country supports mobile + /// number portability the number might not belong to the returned carrier anymore. + /// + /// + public class PhoneNumberToCarrierMapper + { + // Corresponds to resources/carrier/ embedded with LinkBase="carrier". + private const string MAPPING_DATA_DIRECTORY = "carrier."; + + private static readonly Lazy s_instance = + new Lazy(() => new PhoneNumberToCarrierMapper(MAPPING_DATA_DIRECTORY)); + + private readonly PhoneNumberUtil phoneUtil = PhoneNumberUtil.GetInstance(); + private readonly PrefixFileReader prefixFileReader; + + // @VisibleForTesting + internal PhoneNumberToCarrierMapper(string phonePrefixDataDirectory, Assembly asm = null) + { + prefixFileReader = new PrefixFileReader(phonePrefixDataDirectory, asm); + } + + /// + /// Gets the singleton instance. + /// + public static PhoneNumberToCarrierMapper GetInstance() => s_instance.Value; + + /// + /// Returns a carrier name for the given phone number, in the language provided. + /// + /// The carrier name is the one the number was originally allocated to. If the country supports + /// mobile number portability the number might not belong to the returned carrier anymore. + /// If no mapping is found an empty string is returned. + /// + /// + /// This method assumes the validity of the number passed in has already been checked, and that + /// the number is suitable for carrier lookup. We consider mobile and pager numbers possible + /// candidates for carrier lookup. + /// + /// + /// A valid phone number for which we want to get a carrier name. + /// The language code in which the name should be written. + /// A carrier name for the given phone number, or empty string if not found. + public string GetNameForValidNumber(PhoneNumber number, Locale languageCode) + { + return prefixFileReader.GetDescriptionForNumber( + number, languageCode.Language, "", languageCode.Country); + } + + /// + /// Gets the name of the carrier for the given phone number, in the language provided. + /// As per but explicitly checks the validity of + /// the number passed in, and returns empty string if the number is not a mobile or pager number. + /// + /// The phone number for which we want to get a carrier name. + /// The language code in which the name should be written. + /// A carrier name for the given phone number, or empty string if the number passed + /// in is invalid or is not a mobile/pager number. + public string GetNameForNumber(PhoneNumber number, Locale languageCode) + { + if (IsMobile(phoneUtil.GetNumberType(number))) + return GetNameForValidNumber(number, languageCode); + return ""; + } + + /// + /// Gets the name of the carrier for the given phone number only when it is 'safe' to display + /// to users. A carrier name is considered safe if the number is valid and for a region that + /// doesn't support mobile number portability. + /// + /// The phone number for which we want to get a carrier name. + /// The language code in which the name should be written. + /// A carrier name that is safe to display to users, or the empty string. + public string GetSafeDisplayName(PhoneNumber number, Locale languageCode) + { + if (phoneUtil.IsMobileNumberPortableRegion(phoneUtil.GetRegionCodeForNumber(number))) + return ""; + return GetNameForNumber(number, languageCode); + } + + private static bool IsMobile(PhoneNumberType numberType) => + numberType == PhoneNumberType.MOBILE + || numberType == PhoneNumberType.FIXED_LINE_OR_MOBILE + || numberType == PhoneNumberType.PAGER; + } +} diff --git a/csharp/PhoneNumbers/PhoneNumberUtil.cs b/csharp/PhoneNumbers/PhoneNumberUtil.cs index 551a2aca7..cba796825 100644 --- a/csharp/PhoneNumbers/PhoneNumberUtil.cs +++ b/csharp/PhoneNumbers/PhoneNumberUtil.cs @@ -1,4 +1,4 @@ -#nullable disable +#nullable disable /* * Copyright (C) 2009 Google Inc. * @@ -2997,5 +2997,20 @@ public bool CanBeInternationallyDialled(PhoneNumber number) var nationalSignificantNumber = GetNationalSignificantNumber(number); return !IsNumberMatchingDesc(nationalSignificantNumber, metadata.NoInternationalDialling); } + + /// + /// Returns true if the supplied region supports mobile number portability. Returns false for + /// invalid, unknown or regions that don't support mobile number portability. + /// + /// The region for which we want to know whether it supports mobile + /// number portability or not. + /// True if the region supports mobile number portability, false otherwise. + public bool IsMobileNumberPortableRegion(string regionCode) + { + var metadata = GetMetadataForRegion(regionCode); + if (metadata == null) + return false; + return metadata.MobileNumberPortableRegion; + } } } diff --git a/csharp/PhoneNumbers/PhoneNumbers.csproj b/csharp/PhoneNumbers/PhoneNumbers.csproj index 9d41d0751..7d6250e33 100644 --- a/csharp/PhoneNumbers/PhoneNumbers.csproj +++ b/csharp/PhoneNumbers/PhoneNumbers.csproj @@ -44,6 +44,14 @@ + + + + + + + + diff --git a/csharp/PhoneNumbers/PrefixFileReader.cs b/csharp/PhoneNumbers/PrefixFileReader.cs new file mode 100644 index 000000000..4e9a782e4 --- /dev/null +++ b/csharp/PhoneNumbers/PrefixFileReader.cs @@ -0,0 +1,218 @@ +#nullable disable +/* + * Copyright (C) 2013 The Libphonenumber Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection; + +namespace PhoneNumbers +{ + /// + /// A helper class that handles file loading and prefix-based lookup of phone number mappings. + /// + internal class PrefixFileReader + { + private static readonly char[] s_pathSeparators = { '/', '\\' }; + + private readonly MappingFileProvider mappingFileProvider; + private readonly ConcurrentDictionary> availablePhonePrefixMaps = + new ConcurrentDictionary>(); + // Caches GetFileName results to avoid StringBuilder allocations on every lookup. + private readonly ConcurrentDictionary<(int, string, string, string), string> _fileNameCache = + new ConcurrentDictionary<(int, string, string, string), string>(); + // Pre-allocated delegates to avoid closure re-allocation on every GetOrAdd call. + private readonly Func<(int, string, string, string), string> _fileNameFactory; + private readonly Func> _areaCodeMapFactory; + private readonly string phonePrefixDataDirectory; + private readonly string phoneDataZipFile; + private readonly Assembly assembly; + // Normalized entry name -> original FullName, built during construction for O(1) zip lookup. + private readonly Dictionary _zipEntryIndex; + + internal PrefixFileReader(string phonePrefixDataDirectory, Assembly asm = null) + { + SortedDictionary> files; + asm ??= typeof(PrefixFileReader).Assembly; + var prefix = asm.GetName().Name + "." + phonePrefixDataDirectory; + + var zipFile = prefix + "zip"; + var zipStream = asm.GetManifestResourceStream(zipFile); + + if (zipStream != null) + { + using (zipStream) + { + files = LoadFileNamesFromZip(zipStream, out var entryIndex); + _zipEntryIndex = entryIndex; + } + phoneDataZipFile = zipFile; + } + else + { + files = LoadFileNamesFromManifestResources(asm, prefix); + } + + mappingFileProvider = new MappingFileProvider(); + mappingFileProvider.ReadFileConfigs(files); + assembly = asm; + this.phonePrefixDataDirectory = prefix; + _fileNameFactory = k => mappingFileProvider.GetFileName(k.Item1, k.Item2, k.Item3, k.Item4); + _areaCodeMapFactory = key => new Lazy(() => LoadAreaCodeMapFromFile(key)); + } + + // For zipped data: entries follow the pattern "lang/cc.txt". + private static SortedDictionary> LoadFileNamesFromZip( + Stream zipStream, out Dictionary entryIndex) + { + var files = new SortedDictionary>(); + entryIndex = new Dictionary(StringComparer.Ordinal); + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + if (string.IsNullOrWhiteSpace(entry.Name)) + continue; + + entryIndex[entry.FullName.Replace("/", ".").Replace("\\", ".")] = entry.FullName; + + var pathParts = entry.FullName.Split(s_pathSeparators, StringSplitOptions.RemoveEmptyEntries); + if (pathParts.Length < 2) + continue; + + var language = pathParts[0]; + var ccPart = pathParts[pathParts.Length - 1].Split('.')[0]; + if (!int.TryParse(ccPart, out var country)) + continue; + + if (!files.TryGetValue(country, out var languages)) + files[country] = languages = new HashSet(); + languages.Add(language); + } + return files; + } + + // For unzipped data: resources are "{AssemblyName}.{prefix}{lang}.{cc}.txt". + private static SortedDictionary> LoadFileNamesFromManifestResources( + Assembly asm, string prefix) + { + var files = new SortedDictionary>(); + var names = asm.GetManifestResourceNames() + .Where(n => n.StartsWith(prefix, StringComparison.Ordinal)); + foreach (var n in names) + { + // filePart e.g. "en.44.txt" or "zh_Hant.852.txt" + var filePart = n.Substring(prefix.Length); + var parts = filePart.Split('.'); + // Minimum: [lang, cc, "txt"] => length 3 + if (parts.Length < 3) + continue; + + // Last segment is "txt", second-to-last is the country calling code. + // Everything before is the language (joined with '_' to reconstruct "zh_Hant" etc.) + var ccIdx = parts.Length - 2; + if (!int.TryParse(parts[ccIdx], out var country)) + continue; + + var lang = string.Join("_", parts.Take(ccIdx)); + if (lang.Length == 0) + continue; + + if (!files.TryGetValue(country, out var languages)) + files[country] = languages = new HashSet(); + languages.Add(lang); + } + return files; + } + + /// + /// Returns a text description in the given language for the given phone number. + /// Falls back to English when no mapping exists for the requested language, + /// except for Chinese, Japanese, and Korean. + /// + internal string GetDescriptionForNumber(PhoneNumber number, string lang, string script, string region) + { + var countryCallingCode = number.CountryCode; + // As the NANPA data is split into multiple files covering 3-digit areas, use a phone number + // prefix of 4 digits for NANPA instead, e.g. 1650. + var phonePrefix = countryCallingCode != 1 + ? countryCallingCode + : (int)(1000 + number.NationalNumber / 10000000); + var phonePrefixDescriptions = GetPhonePrefixDescriptions(phonePrefix, lang, script, region); + var description = phonePrefixDescriptions?.Lookup(number); + if (string.IsNullOrEmpty(description) && MayFallBackToEnglish(lang)) + { + var defaultMap = GetPhonePrefixDescriptions(phonePrefix, "en", "", ""); + if (defaultMap == null) + return ""; + description = defaultMap.Lookup(number); + } + return description ?? ""; + } + + private static bool MayFallBackToEnglish(string lang) => + !lang.Equals("zh") && !lang.Equals("ja") && !lang.Equals("ko"); + + private AreaCodeMap GetPhonePrefixDescriptions(int prefixMapKey, string language, string script, string region) + { + var fileName = _fileNameCache.GetOrAdd((prefixMapKey, language, script, region), _fileNameFactory); + if (fileName.Length == 0) + return null; + + return availablePhonePrefixMaps.GetOrAdd(fileName, _areaCodeMapFactory).Value; + } + + private AreaCodeMap LoadAreaCodeMapFromFile(string fileName) + { + var fp = phoneDataZipFile != null + ? GetManifestZipFileStream(assembly, phoneDataZipFile, fileName, _zipEntryIndex) + : GetManifestFileStream(assembly, phonePrefixDataDirectory, fileName); + + using (fp) + { + return AreaCodeParser.ParseAreaCodeMap(fp); + } + } + + private static Stream GetManifestFileStream(Assembly asm, string phonePrefixDataDirectory, string fileName) + { + return asm.GetManifestResourceStream(phonePrefixDataDirectory + fileName); + } + + private static Stream GetManifestZipFileStream(Assembly asm, string phoneDataZipFile, string fileName, + Dictionary entryIndex) + { + using var zipStream = asm.GetManifestResourceStream(phoneDataZipFile); + if (zipStream == null) + throw new InvalidOperationException("Manifest zip file stream was null."); + + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + var entry = entryIndex.TryGetValue(fileName, out var originalName) + ? archive.GetEntry(originalName) + : archive.Entries.First(p => p.FullName.Replace("/", ".").Replace("\\", ".") == fileName); + if (entry == null) + throw new InvalidOperationException($"Entry '{fileName}' not found in zip."); + using var entryStream = entry.Open(); + var fileStream = new MemoryStream(); + entryStream.CopyTo(fileStream); + fileStream.Position = 0; + return fileStream; + } + } +} diff --git a/resources/test/carrier/en/1.txt b/resources/test/carrier/en/1650.txt similarity index 100% rename from resources/test/carrier/en/1.txt rename to resources/test/carrier/en/1650.txt