构建默认电话应用程序

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

它不得具有任何通话功能,并且必须仅包含通话的用户界面。它必须处理 Telecom 框架感知的所有呼叫,并且不得对呼叫的性质做出假设。例如,它不得假设呼叫是基于 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实现并请求Connection类的新实例,该实例使用onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest)方法表示新的来电。
  4. 电信子系统通知您的应用应使用onShowIncomingCallUi()方法显示其来电用户界面。
  5. 您的应用使用带有关联的全屏意图的通知显示其来电 UI。有关更多信息,请参阅onShowIncomingCallUi()
  6. 如果用户接听来电,请调用 setActive() 方法;如果用户拒绝来电,请调用 setDisconnected(DisconnectCause) 方法,并将 REJECTED 作为参数,然后调用 destroy() 方法。

其他应用中无法置于保持状态的活动通话

当其他应用中存在无法置于保持状态的活动通话时,请按照以下步骤接听来电

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

发起呼叫

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

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

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

结束通话

要结束通话,请按照以下步骤操作

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

呼叫限制

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

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

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

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

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

  • 当用户处于紧急呼叫状态时,您的应用无法接收或发起呼叫。

  • 如果另一个呼叫应用中存在正在进行的呼叫,而您的应用收到来电,则接听来电将结束其他应用中的所有正在进行的呼叫。您的应用不应显示其通常的来电用户界面。Telecom 框架会显示来电用户界面,并告知用户接听新呼叫将结束其正在进行的呼叫。这意味着,如果用户正在进行 FooTalk 呼叫,而 BarTalk 应用收到来电,则 Telecom 框架会告知用户他们有一个新的 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,则 Telecom 框架将自动回退到使用设备上预加载的拨号器应用。系统会向用户显示通知,告知他们其呼叫已使用预加载的拨号器应用继续。您的应用绝不应返回 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>

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

除了实现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,它将启动您的全屏来电界面。如果用户正在积极使用手机,通知管理器框架会将您的通知显示为悬浮通知。当用户未使用手机时,将使用您的全屏来电界面。例如

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