构建默认电话应用程序

默认电话应用程序允许 Android 电信框架使用角色管理器和通话服务通知您的应用程序通话状态,以创建 Android 设备上默认电话应用程序的替代品,实现 InCallService API。您的实现必须满足以下要求

它不能具有任何通话功能,并且必须仅包含用于通话的用户界面。它必须处理电信框架感知的所有呼叫,并且不要对呼叫的性质做出任何假设。例如,它不应假设呼叫是基于 SIM 卡的电话呼叫,也不应实施基于任何一个 ConnectionService 的呼叫限制,例如对视频呼叫的电话限制的执行。

呼叫应用程序允许用户接收或拨打其设备上的音频或视频呼叫。呼叫应用程序使用其自己的用户界面进行呼叫,而不是使用默认的电话应用程序界面,如下面的屏幕截图所示。

An example of a calling app
使用其自身用户界面的呼叫应用程序示例

Android 框架包含 android.telecom 包,其中包含可帮助您根据电信框架构建呼叫应用程序的类。根据电信框架构建您的应用程序可提供以下好处

  • 您的应用可与设备中的原生电信子系统正确互操作。
  • 您的应用可与同样遵循框架的其他呼叫应用程序正确互操作。
  • 框架可帮助您的应用管理音频和视频路由。
  • 框架可帮助您的应用确定其呼叫是否处于焦点状态。

清单声明和权限

在您的应用清单中,声明您的应用使用MANAGE_OWN_CALLS权限,如下例所示

<manifest  >
    <uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
</manifest>

有关声明应用权限的更多信息,请参阅权限

您必须声明一个服务,该服务指定在您的应用中实现ConnectionService类的类。电信子系统要求该服务声明BIND_TELECOM_CONNECTION_SERVICE权限才能绑定到它。以下示例显示了如何在应用清单中声明服务

<service android:name="com.example.MyConnectionService"
    android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
    <intent-filter>
        <action android:name="android.telecom.ConnectionService" />
    </intent-filter>
</service>

有关声明应用组件(包括服务)的更多信息,请参阅应用组件

实现连接服务

您的呼叫应用必须提供ConnectionService类的实现,电信子系统可以绑定到该实现。您的ConnectionService实现应覆盖以下方法

onCreateOutgoingConnection(PhoneAccountHandle, ConnectionRequest)

您的应用调用placeCall(Uri, Bundle)以创建新的呼出电话时,电信子系统会响应此方法。您的应用返回您Connection类实现的新实例(有关更多信息,请参阅实现连接)以表示新的呼出电话。您可以通过执行以下操作进一步自定义呼出连接

onCreateOutgoingConnectionFailed(PhoneAccountHandle, ConnectionRequest)

当您的应用调用placeCall(Uri, Bundle)方法且无法发起呼出电话时,电信子系统会调用此方法。针对这种情况,您的应用应通知用户(例如,使用警告框或吐司)无法发起呼出电话。如果存在正在进行的紧急呼叫,或者如果存在另一个应用中的正在进行的呼叫(在发起您的呼叫之前无法将其置于保持状态),则您的应用可能无法发起呼叫。

onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest)

当您的应用调用addNewIncomingCall(PhoneAccountHandle, Bundle)方法以通知系统您的应用中的新来电时,电信子系统会调用此方法。您的应用返回您Connection类实现的新实例(有关更多信息,请参阅实现连接)以表示新的来电。您可以通过执行以下操作进一步自定义来电连接

onCreateIncomingConnectionFailed(PhoneAccountHandle, ConnectionRequest)

当您的应用调用addNewIncomingCall(PhoneAccountHandle, Bundle)方法以通知电信子系统有新的来电时,但来电未被允许(有关更多信息,请参阅呼叫限制),电信子系统会调用此方法。您的应用应静默拒绝来电,并可以选择发布通知以告知用户错过的来电。

实现连接

您的应用应创建Connection的子类以表示您的应用中的电话。您应在您的实现中覆盖以下方法

onShowIncomingCallUi()

当您添加新的来电时,电信子系统会调用此方法,并且您的应用应显示其来电 UI。

onCallAudioStateChanged(CallAudioState)

电信子系统调用此方法以通知您的应用当前音频路由或模式已更改。这是响应您的应用使用setAudioRoute(int)方法更改音频模式而调用的。如果系统更改了音频路由(例如,当蓝牙耳机断开连接时),也可能会调用此方法。

onHold()

当电信子系统想要将电话置于保持状态时,会调用此方法。响应此请求,您的应用应保持电话,然后调用setOnHold()方法以通知系统电话正在保持状态。当正在显示您的电话的通话服务(例如 Android Auto)想要转发用户的保持电话请求时,电信子系统可能会调用此方法。如果用户在另一个应用中激活电话,电信子系统也会调用此方法。有关通话服务的更多信息,请参阅InCallService

onUnhold()

当电信子系统想要恢复已置于保持状态的电话时,会调用此方法。您的应用恢复电话后,应调用setActive()方法以通知系统电话不再处于保持状态。当正在显示您的电话的通话服务(例如 Android Auto)想要转发恢复电话的请求时,电信子系统可能会调用此方法。有关通话服务的更多信息,请参阅InCallService

onAnswer()

电信子系统调用此方法以通知您的应用应接听来电。您的应用接听电话后,应调用setActive()方法以通知系统电话已接听。当您的应用添加新的来电并且另一个应用中已存在无法置于保持状态的正在进行的电话时,电信子系统可能会调用此方法。在这些情况下,框架会代表您的应用显示来电 UI。框架提供了一个重载方法,该方法支持指定接听电话的视频状态。有关更多信息,请参阅onAnswer(int)

onReject()

当电信子系统想要拒绝来电时,会调用此方法。您的应用拒绝电话后,应调用setDisconnected(DisconnectCause)并将REJECTED指定为参数。然后,您的应用应调用destroy()方法以通知系统应用已处理电话。当用户拒绝来自您的应用的来电时,电信子系统会调用此方法。

onDisconnect()

当电信子系统想要断开电话连接时,会调用此方法。电话结束后,您的应用应调用setDisconnected(DisconnectCause)方法并将LOCAL指定为参数,以指示用户请求导致电话断开连接。然后,您的应用应调用destroy()方法以通知电信子系统应用已处理电话。当用户通过另一个通话服务(例如 Android Auto)断开电话连接时,系统可能会调用此方法。当必须断开您的电话连接才能允许发起其他电话时,系统也会调用此方法,例如,如果用户想要发起紧急呼叫。有关通话服务的更多信息,请参阅InCallService

处理常见的呼叫场景

在您的呼叫流程中使用ConnectionService API 涉及与android.telecom包中的其他类进行交互。以下部分描述了常见的呼叫场景以及您的应用应如何使用 API 来处理它们。

接听来电

处理来电的流程会根据其他应用中是否有电话而有所不同。流程差异的原因是,当其他应用中有活动电话时,电信框架必须建立一些限制,以确保设备上所有呼叫应用的稳定环境。有关更多信息,请参阅呼叫限制

其他应用中没有活动电话

要在其他应用中没有活动电话时接听来电,请按照以下步骤操作

  1. 您的应用使用其通常的机制接收新的来电。
  2. 使用addNewIncomingCall(PhoneAccountHandle, Bundle)方法通知电信子系统有关新的来电。

  3. 电信子系统绑定到您的应用的 ConnectionService 实现,并使用 onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest) 方法请求一个新的 Connection 类实例,该实例表示新的来电。
  4. 电信子系统通知您的应用,它应该使用 onShowIncomingCallUi() 方法显示其来电用户界面。
  5. 您的应用使用带有关联的全屏意图的通知来显示其来电界面。有关更多信息,请参阅 onShowIncomingCallUi()
  6. 如果用户接听了来电,则调用 setActive() 方法;如果用户拒绝了来电,则调用 setDisconnected(DisconnectCause) 方法,并将 REJECTED 作为参数,然后调用 destroy() 方法。

其他应用中无法保持通话的活动通话

要在其他应用中存在无法保持通话的活动通话时接听来电,请按照以下步骤操作

  1. 您的应用使用其通常的机制接收新的来电。
  2. 使用addNewIncomingCall(PhoneAccountHandle, Bundle)方法通知电信子系统有关新的来电。

  3. 电信子系统绑定到您的应用的 ConnectionService 实现,并使用 onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest) 方法请求一个新的 Connection 对象实例,该实例表示新的来电。
  4. 电信子系统显示您的来电的来电界面。
  5. 如果用户接听了电话,则电信子系统会调用 onAnswer() 方法。您应该调用 setActive() 方法,以指示电信子系统该呼叫现已连接。
  6. 如果用户拒绝了电话,则电信子系统会调用 onReject() 方法。您应该调用 setDisconnected(DisconnectCause) 方法,并将 REJECTED 作为参数,然后调用 destroy() 方法。

发起呼叫

发起呼叫的流程涉及处理由于电信框架施加的限制而无法发起呼叫的可能性。有关更多信息,请参阅 呼叫限制

要发起呼叫,请按照以下步骤操作

  1. 用户在您的应用中发起呼叫。
  2. 使用 placeCall(Uri, Bundle) 方法通知电信子系统有关新的呼叫。请考虑方法参数的以下事项
    • Uri 参数表示呼叫的目标地址。对于常规电话号码,请使用 tel: URI 方案。
    • Bundle 参数允许您通过将应用的 PhoneAccountHandle 对象添加到 EXTRA_PHONE_ACCOUNT_HANDLE 附加信息来提供有关您的呼叫应用的信息。您的应用必须为每个呼叫提供 PhoneAccountHandle 对象。
    • Bundle 参数还允许您通过在 EXTRA_START_CALL_WITH_VIDEO_STATE 附加信息中指定 STATE_BIDIRECTIONAL 值来指定呼叫是否包含视频。请注意,默认情况下,电信子系统会将视频呼叫路由到扬声器。
  3. 电信子系统绑定到您的应用的 ConnectionService 实现。
  4. 如果您的应用无法发起呼叫,则电信子系统将调用 onCreateOutgoingConnectionFailed(PhoneAccountHandle, ConnectionRequest) 方法,以通知您的应用当前无法发起呼叫。您的应用应通知用户无法发起呼叫。
  5. 如果您的应用能够发起呼叫,则电信子系统将调用 onCreateOutgoingConnection(PhoneAccountHandle, ConnectionRequest) 方法。您的应用应返回 Connection 类的实例以表示新的呼叫。有关您应该在连接中设置的属性的更多信息,请参阅 实现连接服务
  6. 呼叫连接后,调用 setActive() 方法通知电信子系统该呼叫处于活动状态。

结束呼叫

要结束呼叫,请按照以下步骤操作

  1. 如果用户终止了呼叫,则调用 setDisconnected(DisconnectCause) 方法并发送 LOCAL 作为参数;如果对方终止了呼叫,则发送 REMOTE 作为参数。
  2. 调用 destroy() 方法。

呼叫限制

为了确保用户获得一致且简单的呼叫体验,电信框架对设备上的呼叫管理施加了一些限制。例如,假设用户安装了两个实现了自管理 ConnectionService API 的呼叫应用,FooTalk 和 BarTalk。在这种情况下,将应用以下限制:

  • 在运行 API 级别 27 或更低版本的设备上,任何给定时间只能有一个应用保持正在进行的呼叫。此限制意味着,当用户使用 FooTalk 应用进行正在进行的呼叫时,BarTalk 应用无法发起或接收新的呼叫。

    在运行 API 级别 28 或更高版本的设备上,如果 FooTalk 和 BarTalk 都声明了 CAPABILITY_SUPPORT_HOLDCAPABILITY_HOLD 权限,则用户可以通过在应用之间切换来发起或接听另一个呼叫,从而保持多个正在进行的呼叫。

  • 如果用户正在进行常规管理的呼叫(例如,使用内置的电话或拨号器应用),则用户无法处于来自呼叫应用发起的呼叫中。这意味着,如果用户正在使用其移动运营商进行常规呼叫,则他们无法同时进行 FooTalk 或 BarTalk 呼叫。

  • 如果用户拨打紧急呼叫,则电信子系统将断开您的应用的呼叫。

  • 在用户进行紧急呼叫时,您的应用无法接收或发起呼叫。

  • 如果另一个呼叫应用中存在正在进行的呼叫,而您的应用接收到来电,则接听来电将结束另一个应用中的所有正在进行的呼叫。您的应用不应显示其通常的来电用户界面。电信框架将显示来电用户界面,并通知用户接听新呼叫将结束其正在进行的呼叫。这意味着,如果用户正在进行 FooTalk 呼叫,而 BarTalk 应用接收到来电,则电信框架将通知用户他们有一个新的 BarTalk 来电,并且接听 BarTalk 呼叫将结束其 FooTalk 呼叫。

成为默认电话应用

默认拨号器/电话应用是在设备处于呼叫状态时提供通话中用户界面的应用。它还为用户提供了一种发起呼叫并在其设备上查看呼叫历史记录的方法。设备捆绑了一个系统提供的默认拨号器/电话应用。用户可以选择一个应用从系统应用接管此角色。希望履行此角色的应用使用 RoleManager 请求他们填充 RoleManager.ROLE_DIALER 角色。

默认电话应用在设备处于呼叫状态且设备未处于汽车模式(即 UiModeManager#getCurrentModeType() 不是 Configuration.UI_MODE_TYPE_CAR)时提供用户界面。

为了填充 RoleManager.ROLE_DIALER 角色,应用必须满足许多要求

  • 它必须处理 Intent#ACTION_DIAL 意图。这意味着应用必须为用户提供拨号盘界面以发起呼叫。
  • 它必须完全实现 InCallService API,并提供来电界面和通话中界面。

注意:如果填充 RoleManager.ROLE_DIALER 的应用在绑定期间返回 null InCallService,则电信框架将自动回退到使用设备上预加载的拨号器应用。系统将向用户显示通知,告知他们其呼叫是使用预加载的拨号器应用继续的。您的应用绝不应返回 null 绑定;这样做意味着它不满足 RoleManager.ROLE_DIALER 的要求。

注意:如果您的应用填充了RoleManager.ROLE_DIALER并在运行时进行了更改,导致它不再满足此角色的要求,RoleManager将自动将您的应用从该角色中移除并关闭您的应用。例如,如果您使用PackageManager.setComponentEnabledSetting(ComponentName, int, int)以编程方式禁用您的应用在其清单中声明的InCallService,您的应用将不再满足RoleManager.ROLE_DIALER的预期要求。

当用户拨打紧急电话时,预加载的拨号器将始终被使用,即使您的应用填充了RoleManager.ROLE_DIALER角色。为了确保拨打紧急电话时的最佳体验,默认拨号器应始终使用TelecomManager.placeCall(Uri, Bundle)来拨打电话(包括紧急电话)。这确保了平台能够验证请求来自默认拨号器。如果非预加载的拨号器应用使用Intent#ACTION_CALL拨打紧急电话,它将使用Intent#ACTION_DIAL提升到预加载的拨号器应用进行确认;这是一种次优的用户体验。

下面是一个InCallService的清单注册示例。元数据TelecomManager#METADATA_IN_CALL_SERVICE_UI指示此特定InCallService实现旨在替换内置的通话界面。元数据TelecomManager#METADATA_IN_CALL_SERVICE_RINGING指示此InCallService将播放来电铃声。有关在您的应用中显示来电界面和播放铃声的更多信息,请参见下文

 <service android:name="your.package.YourInCallServiceImplementation"
          android:permission="android.permission.BIND_INCALL_SERVICE"
          android:exported="true">
      <meta-data android:name="android.telecom.IN_CALL_SERVICE_UI" android:value="true" />
      <meta-data android:name="android.telecom.IN_CALL_SERVICE_RINGING"
          android:value="true" />
      <intent-filter>
          <action android:name="android.telecom.InCallService"/>
      </intent-filter>
 </service>

注意:您不应使用属性android:exported="false"标记您的InCallService;这样做会导致在通话期间无法绑定到您的实现。

除了实现InCallService API 外,您还必须在清单中声明一个处理Intent#ACTION_DIAL意图的活动。以下示例说明了如何执行此操作

 <activity android:name="your.package.YourDialerActivity"
           android:label="@string/yourDialerActivityLabel">
      <intent-filter>
           <action android:name="android.intent.action.DIAL" />
           <category android:name="android.intent.category.DEFAULT" />
      </intent-filter>
      <intent-filter>
           <action android:name="android.intent.action.DIAL" />
           <category android:name="android.intent.category.DEFAULT" />
           <data android:scheme="tel" />
      </intent-filter>
 </activity>

当用户安装您的应用并首次运行时,您应使用RoleManager提示用户,询问他们是否希望您的应用成为新的默认电话应用。

以下代码显示了您的应用如何请求成为默认电话/拨号器应用

 private static final int REQUEST_ID = 1;

 public void requestRole() {
     RoleManager roleManager = (RoleManager) getSystemService(ROLE_SERVICE);
     Intent intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER);
     startActivityForResult(intent, REQUEST_ID);
 }

 public void onActivityResult(int requestCode, int resultCode, Intent data) {
     if (requestCode == REQUEST_ID) {
         if (resultCode == android.app.Activity.RESULT_OK) {
             // Your app is now the default dialer app
         } else {
             // Your app is not the default dialer app
         }
     }
 }

可穿戴设备的 InCallService 访问权限

    如果您的应用是第三方伴侣应用,并且想要访问 InCallService API,您的应用可以执行以下操作

    1. 在您的清单中声明 MANAGE_ONGOING_CALLS 权限
    2. 通过CompanionDeviceManager API 将其与物理可穿戴设备关联,作为伴侣应用。请参阅:https://developer.android.com/guide/topics/connectivity/companion-device-pairing
    3. 使用 BIND_INCALL_SERVICE 权限实现此 InCallService

显示来电通知

当您的应用通过InCallService#onCallAdded(Call)接收新的来电时,它负责为来电显示来电界面。它应该使用NotificationManager API 发布新的来电通知来实现这一点。

在您的应用声明元数据TelecomManager#METADATA_IN_CALL_SERVICE_RINGING的地方,它负责播放来电铃声。您的应用应创建一个NotificationChannel,该通道指定所需的铃声。例如

 NotificationChannel channel = new NotificationChannel(YOUR_CHANNEL_ID, "Incoming Calls",
          NotificationManager.IMPORTANCE_MAX);
 // other channel setup stuff goes here.

 // We'll use the default system ringtone for our incoming call notification channel.  You can
 // use your own audio resource here.
 Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
 channel.setSound(ringtoneUri, new AudioAttributes.Builder()
          // Setting the AudioAttributes is important as it identifies the purpose of your
          // notification sound.
          .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
          .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
      .build());

 NotificationManager mgr = getSystemService(NotificationManager.class);
 mgr.createNotificationChannel(channel);

当您的应用收到新的来电时,它会为来电创建一个Notification,并将其与您的来电通知通道关联。您可以在通知上指定一个PendingIntent,该PendingIntent 将启动您的全屏来电界面。如果用户正在积极使用手机,则通知管理器框架会将您的通知显示为悬浮通知。当用户不使用手机时,将改为使用您的全屏来电界面。例如

 // Create an intent which triggers your fullscreen incoming call user interface.
 Intent intent = new Intent(Intent.ACTION_MAIN, null);
 intent.setFlags(Intent.FLAG_ACTIVITY_NO_USER_ACTION | Intent.FLAG_ACTIVITY_NEW_TASK);
 intent.setClass(context, YourIncomingCallActivity.class);
 PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_MUTABLE_UNAUDITED);
 // Build the notification as an ongoing high priority item; this ensures it will show as
 // a heads up notification which slides down over top of the current content.
 final Notification.Builder builder = new Notification.Builder(context);
 builder.setOngoing(true);
 builder.setPriority(Notification.PRIORITY_HIGH);
 // Set notification content intent to take user to the fullscreen UI if user taps on the
 // notification body.
 builder.setContentIntent(pendingIntent);
 // Set full screen intent to trigger display of the fullscreen UI when the notification
 // manager deems it appropriate.
 builder.setFullScreenIntent(pendingIntent, true);
 // Setup notification content.
 builder.setSmallIcon( yourIconResourceId );
 builder.setContentTitle("Your notification title");
 builder.setContentText("Your notification content.");
 // Use builder.addAction(..) to add buttons to answer or reject the call.
 NotificationManager notificationManager = mContext.getSystemService(
     NotificationManager.class);
 notificationManager.notify(YOUR_CHANNEL_ID, YOUR_TAG, YOUR_ID, builder.build());
```