Convert incoming numbers to E.164 prior to range matching.
TelephonyManager#requestNumberVerification performs range checking on the
original unformatted phone number from the network. In some regions like
the UK, the country prefix may not be present and a leading `0` may be
present in the phone number.
To ensure that these numbers are properly handled, we will now use
PhoneNumberUtils to convert the incoming number from the network into
`E.164` format prior to passing for match checking. Since a 0 prefixed
number received from a UK carrier is formatted correctly with the `+44`
country code, this ensures that these numbers are handled correctly
by the matching API.
Test: Added new unit tests to cover matching of these numbers.
Test: Manual testing using shell command to confirm correct behavior of
the API from an end-to-end standpoint.
Test: Via test app and shell override.
Flag: com.android.internal.telephony.flags.robust_number_verification
Fixes: 400984263
Change-Id: I8bef2480af3a9bb0337eb5a1bdc08cf39bc06a9d
diff --git a/src/com/android/phone/NumberVerificationManager.java b/src/com/android/phone/NumberVerificationManager.java
index 2298d40..5789fa0 100644
--- a/src/com/android/phone/NumberVerificationManager.java
+++ b/src/com/android/phone/NumberVerificationManager.java
@@ -22,6 +22,7 @@
import android.os.RemoteException;
import android.telephony.NumberVerificationCallback;
import android.telephony.PhoneNumberRange;
+import android.telephony.PhoneNumberUtils;
import android.telephony.ServiceState;
import android.text.TextUtils;
import android.util.Log;
@@ -30,11 +31,15 @@
import com.android.internal.telephony.INumberVerificationCallback;
import com.android.internal.telephony.Phone;
import com.android.internal.telephony.PhoneFactory;
+import com.android.internal.telephony.flags.Flags;
+import com.android.telephony.Rlog;
/**
* Singleton for managing the call based number verification requests.
*/
public class NumberVerificationManager {
+ private static final String TAG = "NumberVerification";
+
interface PhoneListSupplier {
Phone[] getPhones();
}
@@ -63,16 +68,40 @@
* Check whether the incoming call matches one of the active filters. If so, call the callback
* that says that the number has been successfully verified.
* @param number A phone number
+ * @param networkCountryISO the network country ISO for the number
* @return true if the number matches, false otherwise
*/
- public synchronized boolean checkIncomingCall(String number) {
+ public synchronized boolean checkIncomingCall(String number, String networkCountryISO) {
if (mCurrentRange == null || mCallback == null) {
return false;
}
- if (mCurrentRange.matches(number)) {
+ Log.i(TAG, "checkIncomingCall: number=" + Rlog.piiHandle(number) + ", country="
+ + networkCountryISO);
+
+ String numberInE164Format;
+ if (Flags.robustNumberVerification()) {
+ // Reformat the number in E.164 format prior to performing matching.
+ numberInE164Format = PhoneNumberUtils.formatNumberToE164(number,
+ networkCountryISO);
+ if (TextUtils.isEmpty(numberInE164Format)) {
+ // Parsing failed, so we will fall back to just passing the number as-is and hope
+ // for the best. Chances are this number is an unknown number (ie no caller id),
+ // so it is most likely empty.
+ numberInE164Format = number;
+ }
+ } else {
+ // Default behavior.
+ numberInE164Format = number;
+ }
+
+ if (mCurrentRange.matches(numberInE164Format)) {
mCurrentRange = null;
try {
+ // Pass back the network-matched number as-is to the caller of
+ // TelephonyManager#requestNumberVerification -- do not send them the E.164 format
+ // number as that changes the format of the number from what the API consumer may
+ // be expecting.
mCallback.onCallReceived(number);
return true;
} catch (RemoteException e) {
@@ -187,6 +216,12 @@
* @param pkgName
*/
static void overrideAuthorizedPackage(String pkgName) {
+ // Make sure we don't have any lingering callbacks kicking around as this could crash other
+ // test runs that follow; also old invocations from previous test invocations could also
+ // mess up things here too.
+ getInstance().mCallback = null;
+ getInstance().mCurrentRange = null;
+ getInstance().mHandler.removeMessages(0);
sAuthorizedPackageOverride = pkgName;
}
}
diff --git a/src/com/android/phone/TelephonyShellCommand.java b/src/com/android/phone/TelephonyShellCommand.java
index cd6a369..85d4ab7 100644
--- a/src/com/android/phone/TelephonyShellCommand.java
+++ b/src/com/android/phone/TelephonyShellCommand.java
@@ -613,7 +613,7 @@
pw.println(" numverify override-package PACKAGE_NAME;");
pw.println(" Set the authorized package for number verification.");
pw.println(" Leave the package name blank to reset.");
- pw.println(" numverify fake-call NUMBER;");
+ pw.println(" numverify fake-call NUMBER <NETWORK_COUNTRY_ISO>");
pw.println(" Fake an incoming call from NUMBER. This is for testing. Output will be");
pw.println(" 1 if the call would have been intercepted, 0 otherwise.");
}
@@ -1091,8 +1091,16 @@
return 0;
}
case NUMBER_VERIFICATION_FAKE_CALL: {
+ String number = getNextArg();
+ String country = getNextArg();
+ if (country == null) {
+ // No locale provided, default to current locale.
+ Locale currentLocale = Locale.getDefault();
+ country = currentLocale.getCountry();
+ }
+ Log.i(TAG, "numberVerificationFakeCall: " + number + " Locale: " + country);
boolean val = NumberVerificationManager.getInstance()
- .checkIncomingCall(getNextArg());
+ .checkIncomingCall(number, country);
getOutPrintWriter().println(val ? "1" : "0");
return 0;
}
diff --git a/src/com/android/services/telephony/PstnIncomingCallNotifier.java b/src/com/android/services/telephony/PstnIncomingCallNotifier.java
index 3b74c6f..fce3d91 100644
--- a/src/com/android/services/telephony/PstnIncomingCallNotifier.java
+++ b/src/com/android/services/telephony/PstnIncomingCallNotifier.java
@@ -33,8 +33,10 @@
import com.android.internal.telephony.CallStateException;
import com.android.internal.telephony.Connection;
import com.android.internal.telephony.GsmCdmaPhone;
+import com.android.internal.telephony.LocaleTracker;
import com.android.internal.telephony.Phone;
import com.android.internal.telephony.PhoneConstants;
+import com.android.internal.telephony.ServiceStateTracker;
import com.android.internal.telephony.cdma.CdmaCallWaitingNotification;
import com.android.internal.telephony.imsphone.ImsExternalCallTracker;
import com.android.internal.telephony.imsphone.ImsExternalConnection;
@@ -149,6 +151,19 @@
}
/**
+ * Note: Same logic as
+ * {@link com.android.phone.PhoneInterfaceManager#getNetworkCountryIsoForPhone(int)}.
+ * @return the network country ISO for the current phone, or {@code null} if not known.
+ */
+ private String getNetworkCountryIso() {
+ ServiceStateTracker sst = mPhone.getServiceStateTracker();
+ if (sst == null) return null;
+ LocaleTracker lt = sst.getLocaleTracker();
+ if (lt == null) return null;
+ return lt.getCurrentCountry();
+ }
+
+ /**
* Verifies the incoming call and triggers sending the incoming-call intent to Telecom.
*
* @param asyncResult The result object from the new ringing event.
@@ -161,7 +176,7 @@
// Check if we have a pending number verification request.
if (connection.getAddress() != null) {
if (NumberVerificationManager.getInstance()
- .checkIncomingCall(connection.getAddress())) {
+ .checkIncomingCall(connection.getAddress(), getNetworkCountryIso())) {
// Disconnect the call if it matches, after a delay
mHandler.postDelayed(() -> {
try {
diff --git a/testapps/TelephonyManagerTestApp/Android.bp b/testapps/TelephonyManagerTestApp/Android.bp
index 0ff917e..28cad76 100644
--- a/testapps/TelephonyManagerTestApp/Android.bp
+++ b/testapps/TelephonyManagerTestApp/Android.bp
@@ -9,4 +9,7 @@
javacflags: ["-parameters"],
platform_apis: true,
certificate: "platform",
+ static_libs: [
+ "androidx.appcompat_appcompat",
+ ],
}
diff --git a/testapps/TelephonyManagerTestApp/AndroidManifest.xml b/testapps/TelephonyManagerTestApp/AndroidManifest.xml
index 6392c26..dd1d624 100644
--- a/testapps/TelephonyManagerTestApp/AndroidManifest.xml
+++ b/testapps/TelephonyManagerTestApp/AndroidManifest.xml
@@ -41,6 +41,20 @@
</meta-data>
</activity>
+ <activity android:name=".NumberVerificationActivity"
+ android:label="Number Verification"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <action android:name="android.intent.action.SEARCH"/>
+ <category android:name="android.intent.category.DEFAULT"/>
+ <category android:name="android.intent.category.LAUNCHER"/>
+ </intent-filter>
+ <meta-data android:name="android.app.searchable"
+ android:resource="@xml/searchable">
+ </meta-data>
+ </activity>
+
<activity android:name=".CallingMethodActivity"
android:label="CallingMethodActivity"
android:exported="true">
diff --git a/testapps/TelephonyManagerTestApp/res/layout/number_verification.xml b/testapps/TelephonyManagerTestApp/res/layout/number_verification.xml
new file mode 100644
index 0000000..a82e54e
--- /dev/null
+++ b/testapps/TelephonyManagerTestApp/res/layout/number_verification.xml
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2025 The Android Open Source Project
+
+ 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.
+-->
+
+<LinearLayout
+ xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical" >
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" >
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15dp"
+ android:text="Country Code:" />
+ <EditText
+ android:id="@+id/countryCode"
+ android:inputType="text"
+ android:text="100"
+ android:layout_width="50dp"
+ android:layout_height="wrap_content" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" >
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15dp"
+ android:text="Prefix:" />
+ <EditText
+ android:id="@+id/prefix"
+ android:inputType="text"
+ android:text="100"
+ android:layout_width="50dp"
+ android:layout_height="wrap_content" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" >
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15dp"
+ android:text="Lower Bound:" />
+ <EditText
+ android:id="@+id/lowerBound"
+ android:inputType="text"
+ android:text="100"
+ android:layout_width="50dp"
+ android:layout_height="wrap_content" />
+ </LinearLayout>
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal" >
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15dp"
+ android:text="Upper Bound:" />
+ <EditText
+ android:id="@+id/upperBound"
+ android:inputType="text"
+ android:text="100"
+ android:layout_width="50dp"
+ android:layout_height="wrap_content" />
+ </LinearLayout>
+
+ <Button
+ android:id="@+id/request_verification_button"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="Request Verification" />
+
+ <TextView
+ android:id="@+id/verificationResult"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:textSize="15dp"
+ android:text="result" />
+</LinearLayout>
diff --git a/testapps/TelephonyManagerTestApp/src/com/android/phone/testapps/telephonymanagertestapp/NumberVerificationActivity.java b/testapps/TelephonyManagerTestApp/src/com/android/phone/testapps/telephonymanagertestapp/NumberVerificationActivity.java
new file mode 100644
index 0000000..81ffe36
--- /dev/null
+++ b/testapps/TelephonyManagerTestApp/src/com/android/phone/testapps/telephonymanagertestapp/NumberVerificationActivity.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2025 The Android Open Source Project
+ *
+ * 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.
+ */
+
+package com.android.phone.testapps.telephonymanagertestapp;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.telephony.NumberVerificationCallback;
+import android.telephony.PhoneNumberRange;
+import android.telephony.TelephonyManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+public class NumberVerificationActivity extends Activity {
+ private EditText mCountryCode;
+ private EditText mPrefix;
+ private EditText mLowerBound;
+ private EditText mUpperBound;
+ private Button mRequestVerificationButton;
+ private TextView mResultField;
+ private TelephonyManager mTelephonyManager;
+
+ private NumberVerificationCallback mCallback = new NumberVerificationCallback() {
+ @Override
+ public void onCallReceived(@NonNull String phoneNumber) {
+ mResultField.setText("Received call from " + phoneNumber);
+ }
+
+ @Override
+ public void onVerificationFailed(int reason) {
+ mResultField.setText("Verification failed " + reason);
+ }
+ };
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.number_verification);
+ setupEdgeToEdge(this);
+ mTelephonyManager = getSystemService(TelephonyManager.class);
+ mCountryCode = findViewById(R.id.countryCode);
+ mPrefix = findViewById(R.id.prefix);
+ mLowerBound = findViewById(R.id.lowerBound);
+ mUpperBound = findViewById(R.id.upperBound);
+ mRequestVerificationButton = findViewById(R.id.request_verification_button);
+ mRequestVerificationButton.setOnClickListener(v -> {
+ mTelephonyManager.requestNumberVerification(
+ new PhoneNumberRange(mCountryCode.getText().toString(),
+ mPrefix.getText().toString(), mLowerBound.getText().toString(),
+ mUpperBound.getText().toString()),
+ 60000,
+ getMainExecutor(),
+ mCallback
+ );
+ });
+ mResultField = findViewById(R.id.verificationResult);
+ }
+
+ /**
+ * Given an activity, configure the activity to adjust for edge to edge restrictions.
+ *
+ * @param activity the activity.
+ */
+ public static void setupEdgeToEdge(Activity activity) {
+ ViewCompat.setOnApplyWindowInsetsListener(activity.findViewById(android.R.id.content),
+ (v, windowInsets) -> {
+ Insets insets = windowInsets.getInsets(
+ WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.ime());
+
+ // Apply the insets paddings to the view.
+ v.setPadding(insets.left, insets.top, insets.right, insets.bottom);
+
+ // Return CONSUMED if you don't want the window insets to keep being
+ // passed down to descendant views.
+ return WindowInsetsCompat.CONSUMED;
+ });
+ }
+}
diff --git a/tests/src/com/android/phone/NumberVerificationManagerTest.java b/tests/src/com/android/phone/NumberVerificationManagerTest.java
index f7914ab..56df139 100644
--- a/tests/src/com/android/phone/NumberVerificationManagerTest.java
+++ b/tests/src/com/android/phone/NumberVerificationManagerTest.java
@@ -26,6 +26,9 @@
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import android.platform.test.annotations.RequiresFlagsEnabled;
+import android.platform.test.flag.junit.CheckFlagsRule;
+import android.platform.test.flag.junit.DeviceFlagsValueProvider;
import android.telephony.NumberVerificationCallback;
import android.telephony.PhoneNumberRange;
import android.telephony.ServiceState;
@@ -33,8 +36,10 @@
import com.android.internal.telephony.Call;
import com.android.internal.telephony.INumberVerificationCallback;
import com.android.internal.telephony.Phone;
+import com.android.internal.telephony.flags.Flags;
import org.junit.Before;
+import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
@@ -43,6 +48,10 @@
@RunWith(JUnit4.class)
public class NumberVerificationManagerTest {
+ @Rule
+ public final CheckFlagsRule mCheckFlagsRule =
+ DeviceFlagsValueProvider.createCheckFlagsRule();
+
private static final PhoneNumberRange SAMPLE_RANGE =
new PhoneNumberRange("1", "650555", "0000", "8999");
private static final long DEFAULT_VERIFICATION_TIMEOUT = 100;
@@ -131,7 +140,7 @@
private void verifyDefaultRangeMatching(NumberVerificationManager manager) throws Exception {
String testNumber = "6505550000";
- assertTrue(manager.checkIncomingCall(testNumber));
+ assertTrue(manager.checkIncomingCall(testNumber, "US"));
verify(mCallback).onCallReceived(testNumber);
}
@@ -148,6 +157,45 @@
verifyDefaultRangeMatching(manager);
}
+ /**
+ * Verifies that numbers starting with '0' prefix from the network and lacking the country code
+ * will be correctly compared to a range with the `44` country code.
+ * @throws Exception
+ */
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ROBUST_NUMBER_VERIFICATION)
+ public void testVerificationOfUkNumbersWithZeroPrefix() throws Exception {
+ NumberVerificationManager manager =
+ new NumberVerificationManager(() -> new Phone[]{mPhone1});
+
+ manager.requestVerification(new PhoneNumberRange("44", "7445", "000000", "999999"),
+ mCallback, DEFAULT_VERIFICATION_TIMEOUT);
+ verify(mCallback, never()).onVerificationFailed(anyInt());
+ String testNumber = "0 7445 032046";
+ assertTrue(manager.checkIncomingCall(testNumber, "GB"));
+ verify(mCallback).onCallReceived(testNumber);
+ }
+
+ /**
+ * Similar to {@link #testVerificationOfUkNumbersWithZeroPrefix()}, except verifies that if the
+ * network sent a full qualified UK phone number with the `+44` country code that it would
+ * match the range.
+ * @throws Exception
+ */
+ @Test
+ @RequiresFlagsEnabled(Flags.FLAG_ROBUST_NUMBER_VERIFICATION)
+ public void testVerificationOfUkNumbersWithCountryPrefix() throws Exception {
+ NumberVerificationManager manager =
+ new NumberVerificationManager(() -> new Phone[]{mPhone1});
+
+ manager.requestVerification(new PhoneNumberRange("44", "7445", "000000", "999999"),
+ mCallback, DEFAULT_VERIFICATION_TIMEOUT);
+ verify(mCallback, never()).onVerificationFailed(anyInt());
+ String testNumber = "+447445032046";
+ assertTrue(manager.checkIncomingCall(testNumber, "GB"));
+ verify(mCallback).onCallReceived(testNumber);
+ }
+
@Test
public void testVerificationWorksWithOnePhoneFull() throws Exception {
Call fakeCall = mock(Call.class);
@@ -169,6 +217,6 @@
new NumberVerificationManager(() -> new Phone[]{mPhone1, mPhone2});
manager.requestVerification(SAMPLE_RANGE, mCallback, DEFAULT_VERIFICATION_TIMEOUT);
verifyDefaultRangeMatching(manager);
- assertFalse(manager.checkIncomingCall("this doesn't even matter"));
+ assertFalse(manager.checkIncomingCall("this doesn't even matter", "US"));
}
}