一、使用说明
1.参考链接
?Telecom概述
?构建电话应用
?InCallService
?融云 · 使用telecom构建VoIP通话应用
?callkeep - ConnectionService和ios-Callkit相关的 Flutter plugin package
?Siper - 通过Sip+ConnectionService构建通话应用
2. ConnectionService以及来电去电的说明
?基本可以确定的是,ConnnectionService和Connection,更多的在充当一个呼叫管理的角色,协调不同app之间的通话状态,展示通话界面等。但无法做到端对端通话,需要通过三方进行实现,这与需求也基本吻合。
?不管是通过push还是其他的什么方式,接收来电取决于外部的通知,当收到通知时,才能去操作通话连接的添加。至于去电,完全取决于用户的操作。当然,在通话过程中,通话双方的操作也需要相应状态的反馈,一些基础的比如接听、挂断等操作,要能够让对方的手机感知到这种状态。
?而Android端,push消息的送达与通知权限是否开启没有关系,通知权限只影响通知栏中的消息是否能展示出来。当app被杀死后,push消息会出现未送达或不能及时送达的情况,因此,可以尽量提高消息的优先级,申请忽略电池优化,尽量保证app存活等,并且对于过时的push消息需要注意进行过滤。
3.Android平台后台启动界面的说明
?Android平台在api >= 29 Android 10 以上时,增加了后台启动限制,app处于后台时无法弹出界面,而低版本并没有太多限制,注意一下锁屏显示以及特殊机型的适配即可。当然,这一切的前提是尽量保证app存活。
4.ConnectionService版本限制以及权限要求
?ConnectionService及Connection要求api >= 23,Android 6.0以上。需要声明权限 MANAGE_OWN_CALLS。当api >= 31,Android 12以上时,需要声明READ_PHONE_NUMBERS权限。
?MANAGE_OWN_CALLS为正常权限,声明即可。而READ_PHONE_NUMBERS权限属于危险权限,需要动态申请。(不过在小米11上测试的时候,READ_PHONE_NUMBERS权限声明后便能正常启动了,不过申请一下也没有太大问题,申请的时候没有给到权限确认的弹窗,而是直接通过了)
5.PhoneAccount的Capabilities说明。
?使用ConnectionService需要注册一个PhoneAccount,需要声明Capabilites,一般来说有两种 CAPABILITY_CALL_PROVIDER和CAPABILITY_SELF_MANAGED。
?其中,CALL_PROVIDER是声明此PhoneAccount是可以用来替代SIM电话呼叫的。使用此声明时,telecomManager.addNewIncomingCall添加的电话,会显示系统的呼叫界面,实现自有界面可能需要结合InCallService把整个的默认电话App给替换掉,并且会在系统通话记录中显示,并且需要引导手动打开PhoneAccount权限,不符合需求,权限引导方式放在第四部分参考。
?SELF_MANAGED用来声明此PhoneAccount用来自己管理通话界面,通话记录不会被系统记录,该模式要求api >= 26 Android 8.0以上。经测试,使用此模式时,app后台弹出界面不受后台启动限制。
?因此综合来看,8.0以下低版本收到来电的push消息时直接弹出通话界面即可,而8.0以上机型使用SELF_MANAGED模式弹出通话界面,是一种可行的方案。
6.其他要求
?关于ConnectionService的使用,英文文档并未要求READ_CALL_LOG、READ_PHONE_STATE两个权限。经测试没有这两个权限的话也是正常的。
?app启动自有通话界面时,需要自己处理铃声振动亮屏等问题,可能需要一些相关权限,如振动响铃、亮屏、悬浮窗(目的是将界面置于最上层)。另外,保活相关的权限最好也要有。国内定制机型可能需要引导后台弹出界面、锁屏显示、自启动等权限的打开。
?另外,Connection的连接断开连接时,必须调用setDisconnected和distory,否则新的Connection会认为正在通话中,没办法接通。类似的状态变更主要还有setRing通话中、setActive电话接通等等,需要自行协调这个状态的闭环。
二、实现步骤
1.创建ConnectionService和Connection的子类并于清单文件中声明相关权限
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_PHONE_NUMBERS" />
<service
android:name="xxx.YourConnectionService"
android:exported="true"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
2. 创建PhoneAccount和PhoneAccountHandle
?app中初始化一次即可,可以创建一个单例类来管理。需要注意的是,建议只在Build.VERSION.SDK_INT >= Build.VERSION_CODES.O的时候使用,不要用CAPABILITY_CALL_PROVIDER,因为这种模式无法自定义界面(虽然可以弹出界面,但同时系统的通话界面也会出现,除非使用InCallService接管默认通话应用,很复杂)
telecomManager = (TelecomManager) context.getSystemService(Context.TELECOM_SERVICE);
String phoneAccountId = context.getPackageName();
ComponentName componentName = new ComponentName(context, TestConnectionService.class);
phoneAccountHandle = new PhoneAccountHandle(componentName, phoneAccountId);
phoneAccount = telecomManager.getPhoneAccount(phoneAccountHandle);
if (phoneAccount == null) {
Uri uri = Uri.parse("tel:987654321");
// 使用CAPABILITY_CALL_PROVIDER等类型的PhoneCount需要跳转到通话账号打开权限
int capabilities = PhoneAccount.CAPABILITY_CALL_PROVIDER;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
capabilities = PhoneAccount.CAPABILITY_SELF_MANAGED;
}
phoneAccount = PhoneAccount
.builder(phoneAccountHandle, context.getString(R.string.app_name))
.setAddress(uri)
.setSubscriptionAddress(uri)
.setCapabilities(capabilities)//必须
.build();
}
if (!phoneAccount.isEnabled()) {
telecomManager.registerPhoneAccount(phoneAccount);
}
3.去电或收到来电时,通过telecom添加来/去电
public void displayNewIncomingCall(RongCallSession rongCallSession) {
if (!isPhoneAccountEnable()) {
return;
}
//传递一些必要的信息
String inviterUserId = rongCallSession.getInviterUserId();
UserInfo userInfo = RongUserInfoManager.getInstance().getUserInfo(inviterUserId);
String userName = inviterUserId;
if (userInfo != null) {
userName = userInfo.getName();
}
Bundle extras = new Bundle();
Uri uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, inviterUserId, null);
extras.putParcelable(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, uri);
extras.putString(EXTRA_CALLER_NAME, userName);
extras.putString(EXTRA_CALL_UUID, rongCallSession.getCallId());
//调用addNewIncomingCall添加来电
telecomManager.addNewIncomingCall(phoneAccountHandle, extras);
}
public void startCall(String uuid, String number, String callerName) {
if (!isPhoneAccountEnable()) {
return;
}
//传递一些必要的信息
Bundle extras = new Bundle();
Uri uri = Uri.fromParts(PhoneAccount.SCHEME_TEL, number, null);
Bundle callExtras = new Bundle();
callExtras.putString(EXTRA_CALLER_NAME, callerName);
callExtras.putString(EXTRA_CALL_UUID, uuid);
callExtras.putString(EXTRA_CALL_NUMBER, number);
extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle);
extras.putParcelable(TelecomManager.EXTRA_OUTGOING_CALL_EXTRAS, callExtras);
//调用addNewIncomingCall添加去电
telecomManager.placeCall(uri, extras);
}
4.在ConnectionService对应的方法中创建Connection
去电
?onCreateOutgoingConnection(PhoneAccountHandle, ConnectionRequest)
?onCreateOutgoingConnectionFailed(PhoneAccountHandle, ConnectionRequest)
来电
?onCreateIncomingConnection(PhoneAccountHandle, ConnectionRequest)
?onCreateIncomingConnectionFailed(PhoneAccountHandle, ConnectionRequest)
?其中,ConnectionRequest可以获取到之前addNewIncomingCall和placeCall是添加的extras信息。详见google文档-实现连接服务
创建Connection
@NonNull
private TestConnection createCallConnection(Uri phoneNumber) {
final TestConnection conn = new TestConnection(mContext);
// 信息携带,可以在conn中获取到。
Bundle extras = new Bundle();
conn.setAddress(phoneNumber, PRESENTATION_ALLOWED);
conn.setExtras(extras);
// 简单的通话功能,hold能力一般不需要进行支持,
// 测试加上hold能力时,新加入的系统来电接通时,会提示正在通话中,请不要挂机。
// conn.setConnectionCapabilities(Connection.CAPABILITY_HOLD | Connection.CAPABILITY_SUPPORT_HOLD);
//设置这个属性才会调用Connection的onShowIncomingCallUi方法
conn.setConnectionProperties(Connection.PROPERTY_SELF_MANAGED);
conn.setAudioModeIsVoip(true);
// conn.setRingbackRequested(true);
//conn相关状态
// conn.setInitializing();
// conn.setInitialized();
// conn.setRinging();//响铃
// conn.setDialing();//拨号
// conn.setActive();//接通
// conn.setDisconnected();//注意,通话结束时必须通过这个方法断开连接
// conn.destroy();
return conn;
}
5.来电界面的展示以及Connection状态的后续处理
?理论上讲来电界面是要在Connection的onShowIncomingCallUi方法调用时打开的,实际测试在ConnectionService的onCreateIncomingConnection方法中同样可以startActivity而不受后台启动的限制,当然还要注意onCreateIncomingConnectionFailed连接创建失败时的Connnection状态的协调处理。
?至于去电,用户操作时直接弹出界面,onCreateOutgoingConnection后创建Connection告知telecom对应的状态即可。
?通常情况下,同一时间app当前的Connection一般只会保持一个,可以通过创建一个管理类去控制Connection的创建、实例获取、以及销毁等。
?剩下的就是根据通话状态处理Connection的状态变更问题,在createCallConnection时有相应的注释,需要注意的是,已连接的Connection必须调用setDisconnected和distroy进行销毁,否则新的Connection会认为正在通话中,没办法接通。
?也可以使用通知消息进行一些辅助提示。
// CallNotificationManager instance = CallNotificationManager.getInstance(this);
// instance.createCallNotification(this, request.getAddress().toString());
Intent intent = new Intent(this, TestIncomingCallEmptyActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent);
6.通过特殊方案(音视频会议)实现VoIP视频通话
?融云CallKit 与 CallLib 均依赖其音视频核心能力库 RTCLib以及即时通讯能力库 IMLib,只是封装了拨打、振铃、接听、挂断等一整套呼叫流程来实现音视频通话。因此,使用音视频会议模式实现VoIP通话实质上没有太大区别。
?可以预见,响铃阶段在于需要自行处理用户拨打时房间的创建、IM通知的推送以及接收,当被叫方拒绝加入房间时同样对呼叫方进行反馈,通话双方均进入房间时视为电话接通,一方离开房间时视为电话挂断,结束后房间的销毁等,总体来说大同小异。
7.总结
?整套流程下来,ConnectionService的作用好像并没有那么明显,仅仅作为了一个高版本后台启动activity的渠道之一。由于系统内部通话状态的共享,因此处理新的来电或者外拨电话时,也有一定的作用,SELF_MANAGER模式的权限较低,因此仅仅限制了不能外拨电话、新的来电接通时如果有hold能力时(见注释)提示请不要挂机、通知提示等等,总体看来比较鸡肋。
?常见的三方应用,好像也没有使用这个来实现VoIP通话的。
?一般来说,除了Android Q以上限制了后台弹出界面以外,其他的版本后台均可以直接弹出Activity作为通话界面,然后辅以通知栏提示等方式(融云后台收到来电在Q以上只有通知栏)。
?然后补充一点悬浮窗权限的问题。在开发过程中将activity显示在最上层,我们很有可能首先想到的就是悬浮窗,但实际上,来电界面通过activity的方式进行实现更加灵活。那么在Q以上,后台该如何弹出Activity呢,经过测试,还是这个悬浮窗权限,在原生系统中,叫做允许应用覆盖在其它应用之上,有了这个权限以后,无论是锁屏显示,还是后台弹出activity,都没有问题。不过在国内的手机上就不一定了,例如某米,就把权限分为了悬浮窗、后台弹出界面、锁屏显示等等,必须允许后台弹出界面,才可以在后台弹出Activity,因此,还得进行一定的适配。
?因此,VoIP来电界面的实现方案:1,直接通过悬浮窗实现,所有版本申请悬浮窗权限即可。2,通过Activity进行实现(使用ConnectionService也一样,但api限制严重而且鸡肋),高版本申请悬浮窗权限,特殊机型进行权限引导。3,Activity+FloatingWindow混合模式。
ps:
?如何测试后台能否弹出Activity,最常用的可能就是delay一个消息了。但这个delay的时间需要注意一下,activity退到桌面,系统并不会立即认为app退到后台了,而是1分钟后。因此,如果delay时间小于1分钟,那这个测试是无效的。
?Android8.0以上,增加了前台Service的限制,很多时候我们会认为,8.0以上必须要启动一个前台Service。实际上并不是,前台界面同样可以启动后台Service。那么启动的后台Service会在什么时间ANR呢,答案是,app退到桌面后1分15秒,至于为什么比Activity多了15秒,感兴趣可以自行研究。
三、问题记录
?ConnectionService推出的来电,会被推到蓝牙设备上,但是在蓝牙设备上进行挂断操作时,无法更新connection的连接状态,也暂未找到处理方式。但是铃声静音操作正常,不确定是不是蓝牙设备的问题。遇到这种问题时,只能通过手机端进行挂断或接听,是一个风险点。
?在使用融云三方sdk时,出现了具有READ_PHONE_STATE权限的时候,来电直接被拒绝的问题。工单回复callkit代码有来运营商电话就自动挂断融云通话的逻辑,需要用的源码集成的callkit把RTCPhoneStateReceiver这个注释掉,测试来电是没问题了,但是,来电接通时也有一个运营商电话的状态判断,因此,接通的瞬间就被挂断掉了,着实难受。因此需要使用限制maxSdkVersion的方式排除掉这个权限(有些三方库可能会在manifest中声明然后被merge进来,因此不添加也不行)
<uses-permission
android:name="android.permission.READ_PHONE_STATE"
android:maxSdkVersion="20" />
四、其他说明
?感兴趣可以研究下系统TelephonyConnectionService和SIP通话SipConnectionService的Connection实现,其内部通过Phone和SipPhone进行相关的通话操作,不过SipManager在Android 12的时候已被官方标记@deprecated。
?测试时VoIP功能通过融云sdk进行实现,其实无论采用什么方案实现,主要流程都大同小异,着重关注通话状态、通知?;?/strong>、版本适配、权限处理等问题,其他方面灵活处理即可。??;詈腿ㄏ奘且桓鲇篮愕幕疤猓馐只榭龌购玫?,基本都有谷歌全家桶,并且也没有国内各种乱七八糟的定制权限。国内的话就尽量吧。。。
另附上一些额外内容:
打开PhoneAccount管理界面
public void openPhoneAccountManager(Context context) {
Intent intent = new Intent(TelecomManager.ACTION_CHANGE_PHONE_ACCOUNTS);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
context.startActivity(intent);
}
来电通知
@RequiresApi(api = Build.VERSION_CODES.O)
public void initChannel() {
@SuppressLint("WrongConstant") NotificationChannel channel = new NotificationChannel(CALL_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 = mContext.getSystemService(NotificationManager.class);
mgr.createNotificationChannel(channel);
}
public void createCallNotification(Context context, String phoneNumber) {
// 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, TestIncomingCallActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_MUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
// 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;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
builder = new Notification.Builder(context, CALL_CHANNEL_ID);
} else {
builder = new Notification.Builder(context);
}
builder.setOngoing(true);
builder.setPriority(Notification.PRIORITY_HIGH);
// Set notification content intent to take user to 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(R.mipmap.ic_launcher);
builder.setContentTitle("New Incoming Call");
builder.setContentText("Caller: " + phoneNumber);
Notification.Action answerAction = new Notification.Action.Builder(Icon.createWithResource(context, R.mipmap.ic_launcher), "Answer", null).build();
builder.addAction(answerAction);
Notification.Action declineIcon = new Notification.Action.Builder(Icon.createWithResource(context, R.mipmap.ic_launcher), "Decline", null).build();
builder.addAction(declineIcon);
// Set notification as insistent to cause your ringtone to loop.
Notification notification = builder.build();
notification.flags |= Notification.FLAG_INSISTENT;
// Use builder.addAction(..) to add buttons to answer or reject the call.
NotificationManager notificationManager = mContext.getSystemService(NotificationManager.class);
notificationManager.notify(CALL_CHANNEL_ID, 1001, notification);
}