ConnectionService在VoIP电话中的应用

一、使用说明

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);
    }
最后编辑于
?著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,100评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,308评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事?!?“怎么了?”我有些...
    开封第一讲书人阅读 159,718评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,275评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,376评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,454评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,464评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,248评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,686评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,974评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,150评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,817评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,484评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,140评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,374评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,012评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,041评论 2 351

推荐阅读更多精彩内容