构建默认电话应用

默认电话应用允许 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) 方法但无法拨出电话时调用此方法。针对这种情况,您的应用应告知用户(例如,使用提醒框或 Toast)无法拨出电话。如果正在进行紧急呼叫,或者另一应用中正在进行的呼叫在您拨打电话之前无法保持,您的应用可能无法拨出电话。

onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest)

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

onCreateIncomingConnectionFailed(PhoneAccountHandle, ConnectionRequest)

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

实现连接

您的应用应创建 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. 您的应用使用带有关联全屏 intent 的通知显示其来电 UI。有关更多信息,请参阅 onShowIncomingCallUi()
  6. 如果用户接受来电,请调用 setActive() 方法;如果用户拒接来电,请调用 setDisconnected(DisconnectCause),并指定 REJECTED 作为参数,然后调用 destroy() 方法。

其他应用中无法保持的活动呼叫

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

  1. 您的应用使用其通常的机制接收新来电。
  2. 使用 addNewIncomingCall(PhoneAccountHandle, Bundle) 方法通知电信子系统有新来电。
  3. 电信子系统绑定到您的应用的 ConnectionService 实现,并使用 onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest) 方法请求代表新来电的 Connection 对象的新实例。
  4. 电信子系统显示您的来电的来电 UI。
  5. 如果用户接受呼叫,电信子系统会调用 onAnswer() 方法。您应调用 setActive() 方法通知电信子系统呼叫现已连接。
  6. 如果用户拒接呼叫,电信子系统会调用 onReject() 方法。您应调用 setDisconnected(DisconnectCause) 方法,并指定 REJECTED 作为参数,然后调用 destroy() 方法。

拨打拨出电话

拨打拨出电话的流程涉及处理因电信框架施加的限制而无法拨打电话的可能性。有关更多信息,请参阅呼叫限制

要拨打拨出电话,请按照以下步骤操作

  1. 用户在您的应用中发起拨出电话。
  2. 使用 placeCall(Uri, Bundle) 方法通知电信子系统有新拨出电话。请注意方法参数的以下事项
    • The Uri 参数代表正在拨打的地址。对于普通电话号码,请使用 tel: URI 方案。
    • The Bundle 参数允许您通过将应用的 PhoneAccountHandle 对象添加到 EXTRA_PHONE_ACCOUNT_HANDLE extra 来提供有关您的呼叫应用的信息。您的应用必须为每个拨出电话提供 PhoneAccountHandle 对象。
    • The Bundle 参数还允许您通过在 EXTRA_START_CALL_WITH_VIDEO_STATE extra 中指定 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 intent。这意味着应用必须为用户提供拨号盘 UI,以便发起拨出电话。
  • 它必须完全实现 InCallService API,并提供来电 UI 和正在进行的呼叫 UI。

注意:如果填充 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 实现旨在替换内置的通话中 UI。元数据 TelecomManager#METADATA_IN_CALL_SERVICE_RINGING 指示此 InCallService 将播放来电铃声。有关在您的应用中显示来电 UI 和播放铃声的更多信息,请参见下文

 <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>

注意:您不应将 InCallService 标记为 android:exported="false" 属性;这样做可能导致在通话期间绑定到您的实现失败。

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

 <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
         }
     }
 }

Wearable 设备访问 InCallService

    如果您的应用是第三方配套应用并希望访问 InCallService API,您的应用可以执行以下操作

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

显示来电通知

当您的应用通过 InCallService#onCallAdded(Call) 接收到新来电时,它负责显示该来电的来电 UI。它应该使用 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,它将启动您的全屏来电 UI。如果用户正在积极使用手机,通知管理器框架会将您的通知显示为浮动通知。当用户未使用手机时,则改为使用您的全屏来电 UI。例如

 // 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());
```