summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--framework-s/api/system-current.txt1
-rw-r--r--framework-s/java/android/app/ecm/EnhancedConfirmationManager.java20
-rw-r--r--framework-s/java/android/app/ecm/IEnhancedConfirmationManager.aidl3
-rw-r--r--service/api/system-server-current.txt14
-rw-r--r--service/java/com/android/ecm/EnhancedConfirmationCallTrackerService.java80
-rw-r--r--service/java/com/android/ecm/EnhancedConfirmationManagerLocal.java56
-rw-r--r--service/java/com/android/ecm/EnhancedConfirmationManagerLocalImpl.java59
-rw-r--r--service/java/com/android/ecm/EnhancedConfirmationService.java133
-rw-r--r--tests/cts/permissionpolicy/res/raw/android_manifest.xml11
-rw-r--r--tests/cts/permissionui/AndroidManifest.xml18
-rw-r--r--tests/cts/permissionui/src/android/permissionui/cts/EnhancedConfirmationInCallTest.kt231
-rw-r--r--tests/cts/permissionui/src/android/permissionui/cts/VoipCallHelper.kt171
12 files changed, 793 insertions, 4 deletions
diff --git a/framework-s/api/system-current.txt b/framework-s/api/system-current.txt
index 3222eeda8..2f6bbf154 100644
--- a/framework-s/api/system-current.txt
+++ b/framework-s/api/system-current.txt
@@ -6,6 +6,7 @@ package android.app.ecm {
method @NonNull public android.content.Intent createRestrictedSettingDialogIntent(@NonNull String, @NonNull String) throws android.content.pm.PackageManager.NameNotFoundException;
method @RequiresPermission(android.Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES) public boolean isClearRestrictionAllowed(@NonNull String) throws android.content.pm.PackageManager.NameNotFoundException;
method @RequiresPermission(android.Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES) public boolean isRestricted(@NonNull String, @NonNull String) throws android.content.pm.PackageManager.NameNotFoundException;
+ method @FlaggedApi("android.permission.flags.enhanced_confirmation_in_call_apis_enabled") @RequiresPermission(android.Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES) public boolean isUnknownCallOngoing();
method @RequiresPermission(android.Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES) public void setClearRestrictionAllowed(@NonNull String) throws android.content.pm.PackageManager.NameNotFoundException;
field public static final String ACTION_SHOW_ECM_RESTRICTED_SETTING_DIALOG = "android.app.ecm.action.SHOW_ECM_RESTRICTED_SETTING_DIALOG";
}
diff --git a/framework-s/java/android/app/ecm/EnhancedConfirmationManager.java b/framework-s/java/android/app/ecm/EnhancedConfirmationManager.java
index 74062165e..8eaa9354d 100644
--- a/framework-s/java/android/app/ecm/EnhancedConfirmationManager.java
+++ b/framework-s/java/android/app/ecm/EnhancedConfirmationManager.java
@@ -20,6 +20,7 @@ import static android.annotation.SdkConstant.SdkConstantType.BROADCAST_INTENT_AC
import android.annotation.FlaggedApi;
import android.annotation.IntDef;
+import android.annotation.NonNull;
import android.annotation.RequiresPermission;
import android.annotation.SdkConstant;
import android.annotation.SystemApi;
@@ -35,8 +36,6 @@ import android.os.RemoteException;
import android.permission.flags.Flags;
import android.util.ArraySet;
-import androidx.annotation.NonNull;
-
import java.lang.annotation.Retention;
/**
@@ -329,6 +328,23 @@ public final class EnhancedConfirmationManager {
}
/**
+ * Returns whether the enhanced confirmation system thinks a call with an unknown party is
+ * occurring
+ *
+ * @hide
+ */
+ @SystemApi
+ @FlaggedApi(Flags.FLAG_ENHANCED_CONFIRMATION_IN_CALL_APIS_ENABLED)
+ @RequiresPermission(android.Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES)
+ public boolean isUnknownCallOngoing() {
+ try {
+ return mService.isUntrustedCallOngoing();
+ } catch (RemoteException e) {
+ throw e.rethrowFromSystemServer();
+ }
+ }
+
+ /**
* Gets an intent that will open the "Restricted setting" dialog for the specified package
* and setting.
*
diff --git a/framework-s/java/android/app/ecm/IEnhancedConfirmationManager.aidl b/framework-s/java/android/app/ecm/IEnhancedConfirmationManager.aidl
index 5149daa49..833485890 100644
--- a/framework-s/java/android/app/ecm/IEnhancedConfirmationManager.aidl
+++ b/framework-s/java/android/app/ecm/IEnhancedConfirmationManager.aidl
@@ -30,4 +30,7 @@ interface IEnhancedConfirmationManager {
boolean isClearRestrictionAllowed(in String packageName, int userId);
void setClearRestrictionAllowed(in String packageName, int userId);
+
+ boolean isUntrustedCallOngoing();
+
}
diff --git a/service/api/system-server-current.txt b/service/api/system-server-current.txt
index 30fbab484..a3db89370 100644
--- a/service/api/system-server-current.txt
+++ b/service/api/system-server-current.txt
@@ -1,4 +1,18 @@
// Signature format: 2.0
+package com.android.ecm {
+
+ @FlaggedApi("android.permission.flags.enhanced_confirmation_in_call_apis_enabled") public class EnhancedConfirmationCallTrackerService extends android.telecom.InCallService {
+ ctor public EnhancedConfirmationCallTrackerService();
+ }
+
+ @FlaggedApi("android.permission.flags.enhanced_confirmation_in_call_apis_enabled") public interface EnhancedConfirmationManagerLocal {
+ method public void addOngoingCall(@NonNull android.telecom.Call);
+ method public void clearOngoingCalls();
+ method public void removeOngoingCall(@NonNull String);
+ }
+
+}
+
package com.android.permission.persistence {
public interface RuntimePermissionsPersistence {
diff --git a/service/java/com/android/ecm/EnhancedConfirmationCallTrackerService.java b/service/java/com/android/ecm/EnhancedConfirmationCallTrackerService.java
new file mode 100644
index 000000000..407d56f70
--- /dev/null
+++ b/service/java/com/android/ecm/EnhancedConfirmationCallTrackerService.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2024 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.ecm;
+
+import android.annotation.FlaggedApi;
+import android.annotation.Nullable;
+import android.annotation.SystemApi;
+import android.annotation.TargetApi;
+import android.content.Intent;
+import android.os.Build;
+import android.permission.flags.Flags;
+import android.telecom.Call;
+import android.telecom.InCallService;
+
+import com.android.server.LocalManagerRegistry;
+
+/**
+ * @hide
+ *
+ * This InCallService tracks called (both incoming and outgoing), and sends their information to the
+ * EnhancedConfirmationService
+ *
+ **/
+@FlaggedApi(Flags.FLAG_ENHANCED_CONFIRMATION_IN_CALL_APIS_ENABLED)
+@SystemApi(client = SystemApi.Client.SYSTEM_SERVER)
+@TargetApi(Build.VERSION_CODES.BAKLAVA)
+public class EnhancedConfirmationCallTrackerService extends InCallService {
+ private EnhancedConfirmationManagerLocal mEnhancedConfirmationManagerLocal;
+
+ @Override
+ public void onCreate() {
+ super.onCreate();
+ if (Flags.enhancedConfirmationInCallApisEnabled()) {
+ mEnhancedConfirmationManagerLocal =
+ LocalManagerRegistry.getManager(EnhancedConfirmationManagerLocal.class);
+ }
+ }
+
+ @Override
+ public void onCallAdded(@Nullable Call call) {
+ if (mEnhancedConfirmationManagerLocal == null || call == null) {
+ return;
+ }
+
+ mEnhancedConfirmationManagerLocal.addOngoingCall(call);
+ }
+
+ @Override
+ public void onCallRemoved(@Nullable Call call) {
+ if (mEnhancedConfirmationManagerLocal == null || call == null) {
+ return;
+ }
+
+ mEnhancedConfirmationManagerLocal.removeOngoingCall(call.getDetails().getId());
+ }
+
+ /**
+ * When unbound, we should assume all calls have finished. Notify the system of such.
+ */
+ public boolean onUnbind(@Nullable Intent intent) {
+ if (mEnhancedConfirmationManagerLocal != null) {
+ mEnhancedConfirmationManagerLocal.clearOngoingCalls();
+ }
+ return super.onUnbind(intent);
+ }
+}
diff --git a/service/java/com/android/ecm/EnhancedConfirmationManagerLocal.java b/service/java/com/android/ecm/EnhancedConfirmationManagerLocal.java
new file mode 100644
index 000000000..483071716
--- /dev/null
+++ b/service/java/com/android/ecm/EnhancedConfirmationManagerLocal.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2024 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.ecm;
+
+import android.annotation.FlaggedApi;
+import android.annotation.NonNull;
+import android.annotation.SystemApi;
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.permission.flags.Flags;
+import android.telecom.Call;
+
+/**
+ * @hide
+ *
+ * In-process API for the Enhanced Confirmation Service
+ */
+@FlaggedApi(Flags.FLAG_ENHANCED_CONFIRMATION_IN_CALL_APIS_ENABLED)
+@SystemApi(client = SystemApi.Client.SYSTEM_SERVER)
+@TargetApi(Build.VERSION_CODES.BAKLAVA)
+public interface EnhancedConfirmationManagerLocal {
+ /**
+ * Inform the enhanced confirmation service of an ongoing call
+ *
+ * @param call The call to potentially track
+ *
+ */
+ void addOngoingCall(@NonNull Call call);
+
+ /**
+ * Inform the enhanced confirmation service that a call has ended
+ *
+ * @param callId The ID of the call to stop tracking
+ *
+ */
+ void removeOngoingCall(@NonNull String callId);
+
+ /**
+ * Informs the enhanced confirmation service it should clear out any ongoing calls
+ */
+ void clearOngoingCalls();
+}
diff --git a/service/java/com/android/ecm/EnhancedConfirmationManagerLocalImpl.java b/service/java/com/android/ecm/EnhancedConfirmationManagerLocalImpl.java
new file mode 100644
index 000000000..a5c6d3c36
--- /dev/null
+++ b/service/java/com/android/ecm/EnhancedConfirmationManagerLocalImpl.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright (C) 2024 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.ecm;
+
+import android.annotation.NonNull;
+import android.annotation.TargetApi;
+import android.os.Build;
+import android.permission.flags.Flags;
+import android.telecom.Call;
+
+/** @hide */
+@TargetApi(Build.VERSION_CODES.BAKLAVA)
+class EnhancedConfirmationManagerLocalImpl implements EnhancedConfirmationManagerLocal {
+
+ private final EnhancedConfirmationService mService;
+
+ EnhancedConfirmationManagerLocalImpl(EnhancedConfirmationService service) {
+ if (Flags.enhancedConfirmationInCallApisEnabled()) {
+ mService = service;
+ } else {
+ mService = null;
+ }
+ }
+
+ @Override
+ public void addOngoingCall(@NonNull Call call) {
+ if (mService != null) {
+ mService.addOngoingCall(call);
+ }
+ }
+
+ @Override
+ public void removeOngoingCall(@NonNull String callId) {
+ if (mService != null) {
+ mService.removeOngoingCall(callId);
+ }
+ }
+
+ @Override
+ public void clearOngoingCalls() {
+ if (mService != null) {
+ mService.clearOngoingCalls();
+ }
+ }
+}
diff --git a/service/java/com/android/ecm/EnhancedConfirmationService.java b/service/java/com/android/ecm/EnhancedConfirmationService.java
index 73f66609e..1bbba1079 100644
--- a/service/java/com/android/ecm/EnhancedConfirmationService.java
+++ b/service/java/com/android/ecm/EnhancedConfirmationService.java
@@ -19,11 +19,15 @@ package com.android.ecm;
import android.Manifest;
import android.annotation.FlaggedApi;
import android.annotation.IntDef;
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.annotation.SuppressLint;
import android.annotation.UserIdInt;
import android.app.AppOpsManager;
import android.app.ecm.EnhancedConfirmationManager;
import android.app.ecm.IEnhancedConfirmationManager;
import android.app.role.RoleManager;
+import android.content.ContentResolver;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.InstallSourceInfo;
@@ -31,25 +35,33 @@ import android.content.pm.PackageInstaller;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.SignedPackage;
+import android.database.Cursor;
+import android.net.Uri;
import android.os.Binder;
import android.os.Build;
import android.os.SystemConfigManager;
import android.os.UserHandle;
import android.permission.flags.Flags;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.PhoneLookup;
+import android.telecom.Call;
+import android.telecom.PhoneAccount;
+import android.telephony.TelephonyManager;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import androidx.annotation.Keep;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import com.android.internal.util.Preconditions;
import com.android.permission.util.UserUtils;
+import com.android.server.LocalManagerRegistry;
import com.android.server.SystemService;
import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -66,16 +78,35 @@ import java.util.Set;
@Keep
@FlaggedApi(Flags.FLAG_ENHANCED_CONFIRMATION_MODE_APIS_ENABLED)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
+@SuppressLint("MissingPermission")
public class EnhancedConfirmationService extends SystemService {
private static final String LOG_TAG = EnhancedConfirmationService.class.getSimpleName();
private Map<String, List<byte[]>> mTrustedPackageCertDigests;
private Map<String, List<byte[]>> mTrustedInstallerCertDigests;
+ // A map of call ID to call type
+ private final Map<String, Integer> mOngoingCalls = new ArrayMap<>();
+
+ private static final int CALL_TYPE_UNTRUSTED = 0;
+ private static final int CALL_TYPE_TRUSTED = 1;
+ private static final int CALL_TYPE_EMERGENCY = 2;
+ @IntDef(flag = true, value = {
+ CALL_TYPE_UNTRUSTED,
+ CALL_TYPE_TRUSTED,
+ CALL_TYPE_EMERGENCY
+ })
+ @Retention(RetentionPolicy.SOURCE)
+ @interface CallType {}
public EnhancedConfirmationService(@NonNull Context context) {
super(context);
+ LocalManagerRegistry.addManager(EnhancedConfirmationManagerLocal.class,
+ new EnhancedConfirmationManagerLocalImpl(this));
}
+ private ContentResolver mContentResolver;
+ private TelephonyManager mTelephonyManager;
+
@Override
public void onStart() {
Context context = getContext();
@@ -87,6 +118,8 @@ public class EnhancedConfirmationService extends SystemService {
systemConfigManager.getEnhancedConfirmationTrustedInstallers());
publishBinderService(Context.ECM_ENHANCED_CONFIRMATION_SERVICE, new Stub());
+ mContentResolver = getContext().getContentResolver();
+ mTelephonyManager = getContext().getSystemService(TelephonyManager.class);
}
private Map<String, List<byte[]>> toTrustedPackageMap(Set<SignedPackage> signedPackages) {
@@ -99,6 +132,90 @@ public class EnhancedConfirmationService extends SystemService {
return trustedPackageMap;
}
+ void addOngoingCall(Call call) {
+ if (!Flags.enhancedConfirmationInCallApisEnabled()) {
+ return;
+ }
+ if (call.getDetails() == null) {
+ return;
+ }
+ mOngoingCalls.put(call.getDetails().getId(), getCallType(call));
+ }
+
+ void removeOngoingCall(String callId) {
+ if (!Flags.enhancedConfirmationInCallApisEnabled()) {
+ return;
+ }
+ Integer returned = mOngoingCalls.remove(callId);
+ if (returned == null) {
+ // TODO b/379941144: Capture a bug report whenever this happens.
+ }
+ }
+
+ void clearOngoingCalls() {
+ mOngoingCalls.clear();
+ }
+
+ private @CallType int getCallType(Call call) {
+ String number = getPhoneNumber(call);
+ if (number != null && mTelephonyManager.isEmergencyNumber(number)) {
+ return CALL_TYPE_EMERGENCY;
+ } else if (number != null) {
+ return hasContactWithPhoneNumber(number) ? CALL_TYPE_TRUSTED : CALL_TYPE_UNTRUSTED;
+ } else {
+ return hasContactWithDisplayName(call.getDetails().getCallerDisplayName())
+ ? CALL_TYPE_TRUSTED : CALL_TYPE_UNTRUSTED;
+ }
+ }
+
+ private String getPhoneNumber(Call call) {
+ Uri handle = call.getDetails().getHandle();
+ if (handle == null || handle.getScheme() == null) {
+ return null;
+ }
+ if (!handle.getScheme().equals(PhoneAccount.SCHEME_TEL)) {
+ return null;
+ }
+ return handle.getSchemeSpecificPart();
+ }
+
+ private boolean hasContactWithPhoneNumber(String phoneNumber) {
+ if (phoneNumber == null) {
+ return false;
+ }
+ Uri uri = Uri.withAppendedPath(PhoneLookup.CONTENT_FILTER_URI,
+ Uri.encode(phoneNumber));
+ String[] projection = new String[]{
+ PhoneLookup.DISPLAY_NAME,
+ ContactsContract.PhoneLookup._ID
+ };
+ try (Cursor res = mContentResolver.query(uri, projection, null, null)) {
+ return res != null && res.getCount() > 0;
+ }
+ }
+
+ private boolean hasContactWithDisplayName(String displayName) {
+ if (displayName == null) {
+ return false;
+ }
+ Uri uri = ContactsContract.Data.CONTENT_URI;
+ String[] projection = new String[]{PhoneLookup._ID};
+ String selection = StructuredName.DISPLAY_NAME + " = ?";
+ String[] selectionArgs = new String[]{displayName};
+ try (Cursor res = mContentResolver.query(uri, projection, selection, selectionArgs, null)) {
+ return res != null && res.getCount() > 0;
+ }
+ }
+
+ private boolean hasCallOfType(@CallType int callType) {
+ for (int ongoingCallType : mOngoingCalls.values()) {
+ if (ongoingCallType == callType) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private class Stub extends IEnhancedConfirmationManager.Stub {
/** A map of ECM states to their corresponding app op states */
@@ -227,6 +344,17 @@ public class EnhancedConfirmationService extends SystemService {
}
}
+ @Override
+ public boolean isUntrustedCallOngoing() {
+ enforcePermissions("isUntrustedCallOngoing",
+ UserHandle.getUserHandleForUid(Binder.getCallingUid()).getIdentifier());
+ if (hasCallOfType(CALL_TYPE_EMERGENCY)) {
+ // If we have an emergency call, return false always.
+ return false;
+ }
+ return hasCallOfType(CALL_TYPE_UNTRUSTED);
+ }
+
private void enforcePermissions(@NonNull String methodName, @UserIdInt int userId) {
UserUtils.enforceCrossUserPermission(userId, /* allowAll= */ false,
/* enforceForProfileGroup= */ false, methodName, mContext);
@@ -323,6 +451,7 @@ public class EnhancedConfirmationService extends SystemService {
return (applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
}
+ @SuppressLint("WrongConstant")
private void setAppEcmState(@NonNull String packageName, @EcmState int ecmState,
@UserIdInt int userId) throws NameNotFoundException {
int packageUid = getPackageUid(packageName, userId);
diff --git a/tests/cts/permissionpolicy/res/raw/android_manifest.xml b/tests/cts/permissionpolicy/res/raw/android_manifest.xml
index 0cd91b50a..05062e931 100644
--- a/tests/cts/permissionpolicy/res/raw/android_manifest.xml
+++ b/tests/cts/permissionpolicy/res/raw/android_manifest.xml
@@ -9142,6 +9142,17 @@
android:permission="android.permission.BIND_JOB_SERVICE" >
</service>
+ <service android:name="android.app.ecm.EnhancedConfirmationCallTrackerService"
+ android:permission="android.permission.BIND_INCALL_SERVICE"
+ android:featureFlag="android.permission.flags.enhanced_confirmation_in_call_apis_enabled"
+ android:exported="true">
+ <meta-data android:name="android.telecom.INCLUDE_SELF_MANAGED_CALLS"
+ android:value="true" />
+ <intent-filter>
+ <action android:name="android.telecom.InCallService"/>
+ </intent-filter>
+ </service>
+
<service android:name="com.android.server.companion.datatransfer.contextsync.CallMetadataSyncInCallService"
android:permission="android.permission.BIND_INCALL_SERVICE"
android:exported="true">
diff --git a/tests/cts/permissionui/AndroidManifest.xml b/tests/cts/permissionui/AndroidManifest.xml
index 3b80b8d8b..b5c9e2ad0 100644
--- a/tests/cts/permissionui/AndroidManifest.xml
+++ b/tests/cts/permissionui/AndroidManifest.xml
@@ -25,6 +25,7 @@
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
+ <uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<application>
@@ -78,6 +79,23 @@
<meta-data android:name="android.accessibilityservice"
android:resource="@xml/test_accessibilityservice"/>
</service>
+ <service android:name=".VoipHelperTestConnectionService"
+ android:exported="true"
+ android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
+ <intent-filter>
+ <action android:name="android.telecom.ConnectionService" />
+ </intent-filter>
+ </service>
+
+ <service android:name=".EcmInCallTestInCallService"
+ android:permission="android.permission.BIND_INCALL_SERVICE"
+ android:exported="true">
+ <meta-data android:name="android.telecom.INCLUDE_SELF_MANAGED_CALLS"
+ android:value="true" />
+ <intent-filter>
+ <action android:name="android.telecom.InCallService"/>
+ </intent-filter>
+ </service>
</application>
diff --git a/tests/cts/permissionui/src/android/permissionui/cts/EnhancedConfirmationInCallTest.kt b/tests/cts/permissionui/src/android/permissionui/cts/EnhancedConfirmationInCallTest.kt
new file mode 100644
index 000000000..cbf4734d5
--- /dev/null
+++ b/tests/cts/permissionui/src/android/permissionui/cts/EnhancedConfirmationInCallTest.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (C) 2024 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 android.permissionui.cts
+
+import android.Manifest
+import android.app.Instrumentation
+import android.app.ecm.EnhancedConfirmationManager
+import android.content.ContentProviderOperation
+import android.content.Context
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Build
+import android.permission.flags.Flags
+import android.platform.test.annotations.AppModeFull
+import android.platform.test.annotations.RequiresFlagsEnabled
+import android.platform.test.flag.junit.CheckFlagsRule
+import android.platform.test.flag.junit.DeviceFlagsValueProvider
+import android.provider.ContactsContract
+import android.provider.ContactsContract.CommonDataKinds
+import android.provider.ContactsContract.Data
+import android.provider.ContactsContract.RawContacts
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.compatibility.common.util.SystemUtil.callWithShellPermissionIdentity
+import com.android.compatibility.common.util.SystemUtil.runWithShellPermissionIdentity
+import java.util.concurrent.Callable
+import org.junit.After
+import org.junit.AfterClass
+import org.junit.Assert
+import org.junit.Assume
+import org.junit.Before
+import org.junit.BeforeClass
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ * This test verifies the behavior of the Enhanced Confirmation Manager APIs that deal with unknown
+ * callers
+ */
+@AppModeFull(reason = "Instant apps cannot install packages")
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.BAKLAVA, codeName = "Baklava")
+@RequiresFlagsEnabled(Flags.FLAG_ENHANCED_CONFIRMATION_IN_CALL_APIS_ENABLED)
+// @CddTest(requirement = "TBD")
+class EnhancedConfirmationInCallTest {
+ private val ecm = context.getSystemService(EnhancedConfirmationManager::class.java)!!
+ private val packageManager = context.packageManager
+ private val addedContacts = mutableMapOf<String, List<Uri>>()
+
+ @JvmField
+ @Rule
+ val checkFlagsRule: CheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule()
+
+ @Before
+ fun assumeNotAutoOrTv() {
+ Assume.assumeFalse(packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK))
+ Assume.assumeFalse(packageManager.hasSystemFeature(PackageManager.FEATURE_AUTOMOTIVE))
+ }
+
+ companion object {
+ private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
+ private val context: Context = instrumentation.targetContext
+ private lateinit var voipService: VoipCallHelper
+
+ @JvmStatic
+ @BeforeClass
+ fun setupVoipService() {
+ voipService = VoipCallHelper(context)
+ voipService.registerPhoneAccount()
+ }
+
+ @JvmStatic
+ @AfterClass
+ fun tearDownVoipService() {
+ voipService.removePhoneAccount()
+ }
+
+ const val CONTACT_DISPLAY_NAME = "Alice Bobson"
+ const val NON_CONTACT_DISPLAY_NAME = "Eve McEve"
+ const val CONTACT_PHONE_NUMBER = "8888888888"
+ const val NON_CONTACT_PHONE_NUMBER = "1111111111"
+ }
+
+ private fun addContact(displayName: String, phoneNumber: String) {
+ runWithShellPermissionIdentity {
+ val ops: ArrayList<ContentProviderOperation> = ArrayList()
+ ops.add(
+ ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
+ .withValue(RawContacts.ACCOUNT_TYPE, "test type")
+ .withValue(RawContacts.ACCOUNT_NAME, "test account")
+ .build()
+ )
+ ops.add(
+ ContentProviderOperation.newInsert(Data.CONTENT_URI)
+ .withValueBackReference(Data.RAW_CONTACT_ID, 0)
+ .withValue(Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
+ .withValue(CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
+ .build()
+ )
+ ops.add(
+ ContentProviderOperation.newInsert(Data.CONTENT_URI)
+ .withValueBackReference(Data.RAW_CONTACT_ID, 0)
+ .withValue(Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
+ .withValue(CommonDataKinds.Phone.NUMBER, phoneNumber)
+ .build()
+ )
+ val results = context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
+ val resultsForDisplayName = mutableListOf<Uri>()
+ results.forEach { resultsForDisplayName.add(it.uri!!) }
+ addedContacts[displayName] = resultsForDisplayName
+ }
+ }
+
+ private fun removeContact(displayName: String) {
+ runWithShellPermissionIdentity {
+ var totalRowsRemoved = 0
+ for (data in addedContacts[displayName] ?: emptyList()) {
+ totalRowsRemoved += context.contentResolver.delete(data, null)
+ }
+ // There are multiple contacts tables, and removing from the raw_contacts table
+ // can cause row removals from the data table, so we may get some uris that don't
+ // report a delete, but we should get at least one, and not more than the number of uris
+ Assert.assertNotEquals(
+ "Expected at least one contact row to be removed",
+ 0,
+ totalRowsRemoved,
+ )
+ Assert.assertTrue(
+ "Unexpectedly large number of contact rows removed",
+ totalRowsRemoved <= (addedContacts[displayName]?.size ?: 0),
+ )
+ addedContacts.remove(displayName)
+ }
+ }
+
+ @After
+ fun tearDown() {
+ voipService.endCallAndWaitForInactive()
+ addedContacts.keys.forEach { removeContact(it) }
+ }
+
+ private fun getInUnknownCallState(): Boolean {
+ return callWithShellPermissionIdentity { ecm.isUnknownCallOngoing }
+ }
+
+ @Test
+ fun testCannotReadOngoingState_WithoutPermission() {
+ try {
+ ecm.isUnknownCallOngoing
+ Assert.fail()
+ } catch (expected: SecurityException) {
+ Assert.assertTrue(
+ expected.message?.contains(
+ Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES
+ ) == true
+ )
+ }
+
+ val unexpectedException =
+ callWithShellPermissionIdentity(
+ Callable {
+ try {
+ ecm.isUnknownCallOngoing
+ null
+ } catch (unexpected: SecurityException) {
+ // Catching the exception, because exceptions thrown inside
+ // run/callWithShellPermissionIdentity are obscured by the rethrow
+ // from run/call.
+ unexpected
+ }
+ },
+ Manifest.permission.MANAGE_ENHANCED_CONFIRMATION_STATES,
+ )
+ Assert.assertNull(unexpectedException)
+ }
+
+ @Test
+ fun testIncomingCall_NonContact() {
+ voipService.createCallAndWaitForActive(NON_CONTACT_DISPLAY_NAME, NON_CONTACT_PHONE_NUMBER)
+ Assert.assertTrue(getInUnknownCallState())
+ voipService.endCallAndWaitForInactive()
+ Assert.assertFalse(getInUnknownCallState())
+ }
+
+ @Test
+ fun testIncomingCall_Contact_DisplayNameMatches_PhoneNotGiven() {
+ addContact(CONTACT_DISPLAY_NAME, CONTACT_PHONE_NUMBER)
+ // If no phone number is given, the display name will be checked
+ voipService.createCallAndWaitForActive(CONTACT_DISPLAY_NAME, CONTACT_PHONE_NUMBER)
+ Assert.assertFalse(getInUnknownCallState())
+ voipService.endCallAndWaitForInactive()
+ Assert.assertFalse(getInUnknownCallState())
+ }
+
+ @Test
+ fun testIncomingCall_Contact_PhoneNumberMatches() {
+ addContact(CONTACT_DISPLAY_NAME, CONTACT_PHONE_NUMBER)
+ // If the phone number matches, the display name is not checked
+ voipService.createCallAndWaitForActive(NON_CONTACT_DISPLAY_NAME, CONTACT_PHONE_NUMBER)
+ Assert.assertFalse(getInUnknownCallState())
+ voipService.endCallAndWaitForInactive()
+ Assert.assertFalse(getInUnknownCallState())
+ }
+
+ @Test
+ fun testCall_DoesntBecomeTrustedIfCallerAddedDuringCall() {
+ val tempContactDisplay = "TEMP CONTACT"
+ val tempContactPhone = "999-999-9999"
+ voipService.createCallAndWaitForActive(tempContactDisplay, tempContactPhone)
+ addContact(tempContactDisplay, tempContactPhone)
+ // State should not be recomputed just because the contact is newly added
+ Assert.assertTrue(getInUnknownCallState())
+ voipService.endCallAndWaitForInactive()
+ voipService.createCallAndWaitForActive(tempContactDisplay, tempContactPhone)
+ // A new call should recognize our contact, and mark the call as trusted
+ Assert.assertFalse(getInUnknownCallState())
+ }
+}
diff --git a/tests/cts/permissionui/src/android/permissionui/cts/VoipCallHelper.kt b/tests/cts/permissionui/src/android/permissionui/cts/VoipCallHelper.kt
new file mode 100644
index 000000000..480d7bff3
--- /dev/null
+++ b/tests/cts/permissionui/src/android/permissionui/cts/VoipCallHelper.kt
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 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 android.permissionui.cts
+
+import android.content.ComponentName
+import android.content.Context
+import android.net.Uri
+import android.os.Bundle
+import android.os.Process
+import android.permissionui.cts.VoipCallHelper.Companion.EXTRA_DISPLAY_NAME
+import android.permissionui.cts.VoipCallHelper.Companion.awaitingCallStateLatch
+import android.permissionui.cts.VoipCallHelper.Companion.currentActiveConnection
+import android.telecom.Connection
+import android.telecom.ConnectionRequest
+import android.telecom.ConnectionService
+import android.telecom.DisconnectCause
+import android.telecom.PhoneAccount
+import android.telecom.PhoneAccountHandle
+import android.telecom.TelecomManager
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import org.junit.Assert
+
+/** A helper class which can register a phone account, and make/end VOIP phone calls */
+class VoipCallHelper(val context: Context) {
+ private val telecomManager by lazy { context.getSystemService(TelecomManager::class.java) }
+ private lateinit var phoneAccount: PhoneAccount
+ private val accountHandle =
+ PhoneAccountHandle(
+ ComponentName(context, VoipHelperTestConnectionService::class.java),
+ "cts-voip-helper-test",
+ Process.myUserHandle(),
+ )
+
+ init {
+ registerPhoneAccount()
+ }
+
+ companion object {
+ var currentActiveConnection: VoIPConnection? = null
+ var awaitingCallStateLatch: CallPlacedLatch? = null
+
+ const val EXTRA_DISPLAY_NAME = "display_name"
+ const val CUSTOM_ADDRESS_SCHEMA = "custom_schema"
+ const val CALL_STATE_WAIT_MS = 1000L
+ const val CALL_TIMEOUT_MS = 10000L
+ }
+
+ fun registerPhoneAccount() {
+ val phoneAccountBuilder = PhoneAccount.builder(accountHandle, "CTS VOIP HELPER")
+ phoneAccountBuilder.setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED)
+ // see b/343674176. Some OEMs expect the PhoneAccount.getExtras() to be non-null
+ val defaultBundle = Bundle()
+ phoneAccountBuilder.setExtras(defaultBundle)
+
+ // build and register the PhoneAccount via the Platform API
+ phoneAccount = phoneAccountBuilder.build()
+ telecomManager.registerPhoneAccount(phoneAccount)
+ }
+
+ fun removePhoneAccount() {
+ telecomManager.unregisterPhoneAccount(phoneAccount.accountHandle)
+ }
+
+ fun createCallAndWaitForActive(displayName: String?, phoneNumber: String?) {
+ val extras = Bundle()
+
+ val phoneUri =
+ if (phoneNumber != null) {
+ Uri.fromParts(PhoneAccount.SCHEME_TEL, phoneNumber, null)
+ } else {
+ // If we don't have a phone number, provide a custom address URI, like many VOIP
+ // apps that aren't tied to a phone number do
+ Uri.fromParts(CUSTOM_ADDRESS_SCHEMA, "custom_address", null)
+ }
+ if (displayName != null) {
+ extras.putString(EXTRA_DISPLAY_NAME, displayName)
+ }
+ extras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, phoneUri)
+ awaitingCallStateLatch = CallPlacedLatch(phoneUri, displayName)
+ telecomManager.addNewIncomingCall(phoneAccount.accountHandle, extras)
+ Assert.assertTrue(
+ "Timed out waiting for call to start",
+ awaitingCallStateLatch!!.await(CALL_TIMEOUT_MS, TimeUnit.MILLISECONDS),
+ )
+ // TODO b/379941144: Replace wait with waiting until a test InCallService gets a callback
+ Thread.sleep(CALL_STATE_WAIT_MS)
+ }
+
+ fun endCallAndWaitForInactive() {
+ currentActiveConnection?.let { connection ->
+ connection.setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
+ connection.destroy()
+ // TODO b/379941144: Replace wait with waiting until a test InCallService gets a
+ // callback
+ Thread.sleep(CALL_STATE_WAIT_MS)
+ }
+ currentActiveConnection = null
+ }
+}
+
+class CallPlacedLatch(val address: Uri?, val displayName: String?) : CountDownLatch(1) {
+ fun nameAndNumberMatch(connection: Connection): Boolean {
+ return connection.address == address && connection.callerDisplayName == displayName
+ }
+}
+
+class VoIPConnection : Connection() {
+ init {
+ setConnectionProperties(PROPERTY_SELF_MANAGED)
+ setAudioModeIsVoip(true)
+ setActive()
+ }
+
+ override fun onShowIncomingCallUi() {
+ super.onShowIncomingCallUi()
+ setActive()
+ currentActiveConnection = this
+ if (awaitingCallStateLatch?.nameAndNumberMatch(this) == true) {
+ awaitingCallStateLatch?.countDown()
+ }
+ }
+}
+
+class VoipHelperTestConnectionService : ConnectionService() {
+ override fun onCreateOutgoingConnection(
+ connectionManagerPhoneAccount: PhoneAccountHandle,
+ request: ConnectionRequest,
+ ): Connection {
+ return createConnection(request)
+ }
+
+ override fun onCreateIncomingConnection(
+ connectionManagerPhoneAccount: PhoneAccountHandle?,
+ request: ConnectionRequest?,
+ ): Connection {
+ return createConnection(request)
+ }
+
+ private fun createConnection(request: ConnectionRequest?): Connection {
+ val connection = VoIPConnection()
+ if (request?.extras?.containsKey(EXTRA_DISPLAY_NAME) == true) {
+ connection.setCallerDisplayName(
+ request.extras.getString(EXTRA_DISPLAY_NAME),
+ TelecomManager.PRESENTATION_ALLOWED,
+ )
+ connection.setAddress(
+ request.extras.getParcelable(
+ TelecomManager.EXTRA_INCOMING_CALL_ADDRESS,
+ Uri::class.java,
+ ),
+ TelecomManager.PRESENTATION_ALLOWED,
+ )
+ }
+ return connection
+ }
+}