说来奇怪,即时通讯领域的霸主QQ,微信,旗下产品出的腾讯即时通讯IM就像个残疾人一样,这里不对那里不对,要达到生产级别,就不得不去改它很多源码才行。今天先不吐槽其他的,我们看看如何在腾讯Im里面完成语音通话
功能。
大致分为以下几步:
1、集成SDK
dependencies {
implementation 'com.tencent.liteav:LiteAVSDK_TRTC:latest.release'
}
defaultConfig {
ndk {
abiFilters "armeabi", "armeabi-v7a", "arm64-v8a"
}
}
最后,如果这篇对你有一丁点帮助,请点个赞再走吧,谢谢了喂。
-keep class com.tencent.** { *; }
packagingOptions {
pickFirst '**/libc++_shared.so'
doNotStrip "*/armeabi/libYTCommon.so"
doNotStrip "*/armeabi-v7a/libYTCommon.so"
doNotStrip "*/x86/libYTCommon.so"
doNotStrip "*/arm64-v8a/libYTCommon.so"
}
2、实现通话
TRTCAudioCallActivity.java
文件CallService
到项目中,这个Service主要负责处理接听电话的事务(接听电话需要进房需要查询用户信息,生成一个beingCallUserModel
传入)TRTCAudioCallActivity.startCallSomeone(getContext(), mContactList);
发起语音通话,这里的mContactList 如果是单聊或者群聊只邀请一个人,只会有一个model,查询设置这个model的avatar、phone、userid、username、groupId
即可。到此初步集成完毕,可以进行语音通话了。1、Android端的通话逻辑并不完善,让我们来看看它的问题
Log.d(TAG, "Hangup: " + mCurRoomUserSet + " " + mCurInvitedList + " " + mIsInRoom);
if (mIsInRoom) {
if (isCollectionEmpty(mCurRoomUserSet)) {
if (mCurInvitedList.size() > 0) {
//取消
sendModel("", CallModel.VIDEO_CALL_ACTION_SPONSOR_CANCEL);
} else {
//通话结束
sendModel("", CallModel.VIDEO_CALL_ACTION_HANGUP);
}
}
}
stopCall();
exitRoom();
}
并且如果是群聊 ,需要在远端用户退出群主,并且群主里面没有用户的时候发送通话结束的消息即 在preExitRoom
方法里面调用groupHangup
方法,并且退房相关操作需要注释掉,因为groupHangup
方法里面会对房间参数进行判断,需要发消息,然后退房。
当然发送消息并退房并不是所有情况都适用,比如忙线,拒接、超时的时候,就只需要执行退房操作,所以在这些情况下不能调用groupHangup
方法,只判断执行退房操作。
2、解析自定义消息
这个东西看需求,一般情况下,一次通话都会有两条消息,即一条发起通话消息,一条结束(拒绝、忙线、挂断、超时等情况),我这里贴一下我的解析方式和效果图:
private void buildVoiceCallView(ICustomMessageViewGroup parent, MessageInfo info, TRTCAudioCallImpl.CallModel data) {
if (data.action == TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_DIALING) {
// 把自定义消息view添加到TUIKit内部的父容器里
View view = LayoutInflater.from(AndroidApplication.getInstance()).inflate(R.layout.dial_senc_call_message, null, false);
parent.addMessageItemView(view);
TextView tv = view.findViewById(R.id.tv_content);
if (info.isSelf()) {
tv.setText("您发起了语音通话");
} else {
tv.setText("对方发起了语音通话");
}
return;
}
// 把自定义消息view添加到TUIKit内部的父容器里
View view = LayoutInflater.from(AndroidApplication.getInstance()).inflate(R.layout.dial_custom_message, null, false);
parent.addMessageContentView(view);
// 自定义消息view的实现,这里仅仅展示文本信息,并且实现超链接跳转
TextView textView = view.findViewById(R.id.tv_dial_status);
ImageView ivLeft = view.findViewById(R.id.iv_left);
ImageView ivRight = view.findViewById(R.id.iv_right);
if (info.isSelf()) {
ivRight.setVisibility(View.VISIBLE);
ivLeft.setVisibility(View.GONE);
textView.setTextColor(getResources().getColor(R.color.white));
} else {
ivRight.setVisibility(View.GONE);
ivLeft.setVisibility(View.VISIBLE);
textView.setTextColor(getResources().getColor(R.color.color_333333));
}
String text;
switch (data.action) {
case TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_SPONSOR_CANCEL:
text = "已取消";
break;
case TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_REJECT:
text = "已拒绝";
break;
case TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_SPONSOR_TIMEOUT:
text = "无人接听";
break;
case TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_HANGUP:
if (data.duration == 0) {
text = "通话结束";
} else {
text = "通话结束 " + TimeUtils.millis2StringByCorrect(data.duration * 1000, data.duration >= 60 * 60 ? "HH:mm:ss" : "mm:ss");
}
break;
case TRTCAudioCallImpl.CallModel.VIDEO_CALL_ACTION_LINE_BUSY:
text = "忙线中";
break;
default:
text = "未知通话错误";
break;
}
textView.setText(text);
}
1、铃声震动(呼叫和待接听响铃,接听和挂断停止响铃)
showInvitingView()
方法中添加//开始呼叫响铃
if (mRingVibrateHelper != null) { mRingVibrateHelper.initLocalCallRinging();}
showCallingView()
方法中使用//停止响铃if (mRingVibrateHelper != null) { mRingVibrateHelper.stopRing();}
showWaitingResponseView()
方法中使用//响铃或者震动mRingVibrateHelper.initRemoteCallRinging();
if (mRingVibrateHelper != null) {
mRingVibrateHelper.stopRing();
mRingVibrateHelper.releaseMediaPlayer();
}
分享一下响铃震动帮助类TimRingVibrateHelper
/**
* @author leary
* 响铃震动帮助类
*/
public class TimRingVibrateHelper {
private static final String TAG = TimRingVibrateHelper.class.getSimpleName();
/**
* =============响铃 震动相关
*/
private MediaPlayer mMediaPlayer;
private Vibrator mVibrator;
private static TimRingVibrateHelper instance;
public static TimRingVibrateHelper getInstance() {
if (instance == null) {
synchronized (TimRingVibrateHelper.class) {
if (instance == null) {
instance = new TimRingVibrateHelper();
}
}
}
return instance;
}
private TimRingVibrateHelper() {
//铃声相关
mMediaPlayer = new MediaPlayer();
mMediaPlayer.setOnPreparedListener(mp -> {
if (mp != null) {
mp.setLooping(true);
mp.start();
}
});
}
/**
* ==============响铃、震动相关方法========================
*/
public void initLocalCallRinging() {
try {
AssetFileDescriptor assetFileDescriptor = AndroidApplication.getInstance().getResources().openRawResourceFd(R.raw.voip_outgoing_ring);
mMediaPlayer.reset();
mMediaPlayer.setDataSource(assetFileDescriptor.getFileDescriptor(),
assetFileDescriptor.getStartOffset(), assetFileDescriptor.getLength());
assetFileDescriptor.close();
// 设置 MediaPlayer 播放的声音用途
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
AudioAttributes attributes = new AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
.build();
mMediaPlayer.setAudioAttributes(attributes);
} else {
mMediaPlayer.setAudioStreamType(AudioManager.STREAM_VOICE_CALL);
}
mMediaPlayer.prepareAsync();
final AudioManager am = (AudioManager) AndroidApplication.getInstance().getSystemService(Context.AUDIO_SERVICE);
if (am != null) {
am.setSpeakerphoneOn(false);
// 设置此值可在拨打时控制响铃音量
am.setMode(AudioManager.MODE_IN_COMMUNICATION);
// 设置拨打时响铃音量默认值
am.setStreamVolume(AudioManager.STREAM_VOICE_CALL, 8, AudioManager.STREAM_VOICE_CALL);
}
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 判断系统响铃正东相关设置
* 1、系统静音 不震动 就两个都不设置
* 2、静音震动
* 3、只响铃不震动
* 4、响铃且震动
*/
public void initRemoteCallRinging() {
int ringerMode = getRingerMode(AndroidApplication.getInstance());
if (ringerMode != AudioManager.RINGER_MODE_SILENT) {
if (ringerMode == AudioManager.RINGER_MODE_VIBRATE) {
startVibrator();
} else {
if (isVibrateWhenRinging()) {
startVibrator();
}
startRing();
}
}
}
private int getRingerMode(Context context) {
AudioManager audio = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
return audio.getRingerMode();
}
/**
* 开始响铃
*/
private void startRing() {
Uri uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE);
try {
mMediaPlayer.setDataSource(AndroidApplication.getInstance(), uri);
mMediaPlayer.prepareAsync();
} catch (Exception e) {
e.printStackTrace();
Log.e(TAG, "Ringtone not found : " + uri);
try {
uri = RingtoneManager.getValidRingtoneUri(AndroidApplication.getInstance());
mMediaPlayer.setDataSource(AndroidApplication.getInstance(), uri);
mMediaPlayer.prepareAsync();
} catch (Exception e1) {
e1.printStackTrace();
Log.e(TAG, "Ringtone not found: " + uri);
}
}
}
/**
* 开始震动
*/
private void startVibrator() {
if (mVibrator == null) {
mVibrator = (Vibrator) AndroidApplication.getInstance().getSystemService(Context.VIBRATOR_SERVICE);
} else {
mVibrator.cancel();
}
mVibrator.vibrate(new long[]{500, 1000}, 0);
}
/**
* 判断系统是否设置了 响铃时振动
*/
private boolean isVibrateWhenRinging() {
ContentResolver resolver = AndroidApplication.getInstance().getApplicationContext().getContentResolver();
if (Build.MANUFACTURER.equals("Xiaomi")) {
return Settings.System.getInt(resolver, "vibrate_in_normal", 0) == 1;
} else if (Build.MANUFACTURER.equals("smartisan")) {
return Settings.Global.getInt(resolver, "telephony_vibration_enabled", 0) == 1;
} else {
return Settings.System.getInt(resolver, "vibrate_when_ringing", 0) == 1;
}
}
/**
* 停止震动和响铃
*/
public void stopRing() {
if (mMediaPlayer != null) {
mMediaPlayer.reset();
}
if (mVibrator != null) {
mVibrator.cancel();
}
if (AndroidApplication.getInstance() != null) {
//通话时控制音量
AudioManager audioManager = (AudioManager) AndroidApplication.getInstance().getApplicationContext().getSystemService(AUDIO_SERVICE);
audioManager.setMode(AudioManager.MODE_NORMAL);
}
}
/**
* 释放资源
*/
public void releaseMediaPlayer() {
if (mMediaPlayer != null) {
mMediaPlayer.release();
mMediaPlayer = null;
}
if (instance != null) {
instance = null;
}
// 退出此页面后应设置成正常模式,否则按下音量键无法更改其他音频类型的音量
if (AndroidApplication.getInstance() != null) {
AudioManager am = (AudioManager) AndroidApplication.getInstance().getApplicationContext().getSystemService(Context.AUDIO_SERVICE);
if (am != null) {
am.setMode(AudioManager.MODE_NORMAL);
}
}
}
}
2、悬浮窗 实现
1)申请权限
@TargetApi(19)
public static boolean canDrawOverlays(final Context context, boolean needOpenPermissionSetting) {
boolean result = true;
if (Build.VERSION.SDK_INT >= 23) {
try {
boolean booleanValue = (Boolean) Settings.class.getDeclaredMethod("canDrawOverlays", Context.class).invoke((Object) null, context);
if (!booleanValue && needOpenPermissionSetting) {
ArrayList permissionList = new ArrayList();
permissionList.add("android.settings.action.MANAGE_OVERLAY_PERMISSION");
showPermissionAlert(context, context.getString(R.string.tim_float_window_not_allowed), new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
if (-1 == which) {
Intent intent = new Intent("android.settings.action.MANAGE_OVERLAY_PERMISSION", Uri.parse("package:" + context.getPackageName()));
context.startActivity(intent);
}
if (-2 == which) {
Toasty.warning(context, "抱歉,您已拒绝DBC获得您的悬浮窗权限,将影响您接听对方发起的语音通话。").show();
}
}
});
}
Log.i(TAG, "isFloatWindowOpAllowed allowed: " + booleanValue);
return booleanValue;
} catch (Exception var7) {
Log.e(TAG, String.format("getDeclaredMethod:canDrawOverlays! Error:%s, etype:%s", var7.getMessage(), var7.getClass().getCanonicalName()));
return true;
}
} else if (Build.VERSION.SDK_INT < 19) {
return true;
} else {
Object systemService = context.getSystemService(Context.APP_OPS_SERVICE);
Method method;
try {
method = Class.forName("android.app.AppOpsManager").getMethod("checkOp", Integer.TYPE, Integer.TYPE, String.class);
} catch (NoSuchMethodException var9) {
Log.e(TAG, String.format("NoSuchMethodException method:checkOp! Error:%s", var9.getMessage()));
method = null;
} catch (ClassNotFoundException var10) {
var10.printStackTrace();
method = null;
}
if (method != null) {
try {
Integer tmp = (Integer) method.invoke(systemService, 24, context.getApplicationInfo().uid, context.getPackageName());
result = tmp == 0;
} catch (Exception var8) {
Log.e(TAG, String.format("call checkOp failed: %s etype:%s", var8.getMessage(), var8.getClass().getCanonicalName()));
}
}
Log.i(TAG, "isFloatWindowOpAllowed allowed: " + result);
return result;
}
}
当然申请悬浮窗全选会有跳转到设置界面这个过程,所以还需要添加判断是否具有悬浮窗权限的判断过程,这里就留点发挥空间了。
2)将当前通话Activity移动到后台执行
这个很简单,就是将Activity的lunchMode改为SingleInstance模式,然后直接调用moveTaskToBack(true);方法,这里传true,表示任何情况下 都会将Acitivty移动到后台。但是有得必有失,设置为SingleInstance模式会为我们带来一些问题,这些我会在后面说明。
3)绑定悬浮窗服务,开启悬浮窗
创建一个悬浮窗Service,获取WindowManager,在windowManager添加一个自定义的悬浮窗View即可,当然要想悬浮窗可以移动,得重写悬浮窗的,触摸事件。在悬浮窗里面注册一个本地广播,方便改变通话状态,记录通话时间等等。贴一下代码,需要自取。
public class TimFloatWindowService extends Service implements View.OnTouchListener {
private WindowManager mWindowManager;
private WindowManager.LayoutParams wmParams;
private LayoutInflater inflater;
/**
* 浮动布局view
*/
private View mFloatingLayout;
/**
* 容器父布局
*/
private View mMainView;
/**
* 开始触控的坐标,移动时的坐标(相对于屏幕左上角的坐标)
*/
private int mTouchStartX, mTouchStartY, mTouchCurrentX, mTouchCurrentY;
/**
* 开始时的坐标和结束时的坐标(相对于自身控件的坐标)
*/
private int mStartX, mStartY, mStopX, mStopY;
/**
* 判断悬浮窗口是否移动,这里做个标记,防止移动后松手触发了点击事件
*/
private boolean isMove;
/**
* 判断是否绑定了服务
*/
private boolean isServiceBind;
/**
* 通话状态
*/
private TextView mAcceptStatus;
public class TimBinder extends Binder {
public TimFloatWindowService getService() {
return TimFloatWindowService.this;
}
}
private BroadcastReceiver mTimBroadCastReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
if (isServiceBind && CommonI.TIM.BROADCAST_FLAG_FLOAT_STATUS.equals(intent.getAction())
&& mAcceptStatus != null) {
String status = intent.getStringExtra(CommonI.TIM.KEY_ACCEPT_STATUS);
mAcceptStatus.setText(status);
}
}
};
@Override
public IBinder onBind(Intent intent) {
isServiceBind = true;
initFloating();//悬浮框点击事件的处理
return new TimBinder();
}
@Override
public void onCreate() {
super.onCreate();
//设置悬浮窗基本参数(位置、宽高等)
initWindow();
//注册 BroadcastReceiver 监听情景模式的切换
IntentFilter filter = new IntentFilter();
filter.addAction(CommonI.TIM.BROADCAST_FLAG_FLOAT_STATUS);
LocalBroadcastManager.getInstance(this).registerReceiver(mTimBroadCastReceiver, filter);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return super.onStartCommand(intent, flags, startId);
}
@Override
public void onDestroy() {
super.onDestroy();
isServiceBind = false;
if (mFloatingLayout != null) {
// 移除悬浮窗口
mWindowManager.removeView(mFloatingLayout);
mFloatingLayout = null;
}
LocalBroadcastManager.getInstance(this).unregisterReceiver(mTimBroadCastReceiver);
}
/**
* 设置悬浮框基本参数(位置、宽高等)
*/
private void initWindow() {
mWindowManager = (WindowManager) getApplicationContext().getSystemService(Context.WINDOW_SERVICE);
//设置好悬浮窗的参数
wmParams = getParams();
// 悬浮窗默认显示以右上角为起始坐标
wmParams.gravity = Gravity.RIGHT | Gravity.TOP;
// 不设置这个弹出框的透明遮罩显示为黑色
wmParams.format = PixelFormat.TRANSLUCENT;
//悬浮窗的开始位置,因为设置的是从右上角开始,所以屏幕左上角是x=0;y=0
wmParams.x = 40;
wmParams.y = 160;
//得到容器,通过这个inflater来获得悬浮窗控件
inflater = LayoutInflater.from(getApplicationContext());
// 获取浮动窗口视图所在布局
mFloatingLayout = inflater.inflate(R.layout.layout_tim_float_window, null);
// 添加悬浮窗的视图
mWindowManager.addView(mFloatingLayout, wmParams);
}
private WindowManager.LayoutParams getParams() {
wmParams = new WindowManager.LayoutParams();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
wmParams.type = WindowManager.LayoutParams.TYPE_TOAST;
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
wmParams.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
} else {
wmParams.type = WindowManager.LayoutParams.TYPE_PHONE;
}
//设置可以显示在状态栏上
wmParams.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL |
WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR |
WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM |
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE;
//设置悬浮窗口长宽数据
wmParams.width = WindowManager.LayoutParams.WRAP_CONTENT;
wmParams.height = WindowManager.LayoutParams.WRAP_CONTENT;
return wmParams;
}
//加载远端视屏:在这对悬浮窗内内容做操作
private void initFloating() {
//将子View加载进悬浮窗View
//悬浮窗父布局
mMainView = mFloatingLayout.findViewById(R.id.layout_dial_float);
//加载进悬浮窗的子View,这个VIew来自天转过来的那个Activity里面的那个需要加载的View
mAcceptStatus = mFloatingLayout.findViewById(R.id.tv_accept_status);
// View mChildView = renderView.getChildView();
// mMainView.addView(mChildView);//将需要悬浮显示的Viewadd到mTXCloudVideoView中
//悬浮框触摸事件,设置悬浮框可拖动
mMainView.setOnTouchListener(this);
//悬浮框点击事件
mMainView.setOnClickListener(v -> {
//绑定了服务才跳转,不绑定服务不跳转
if (!isServiceBind) {
return;
}
//在这里实现点击重新回到Activity
//从该service跳转至该activity会将该activity从后台唤醒,所以activity会走onReStart()
Intent intent = new Intent(TimFloatWindowService.this, TRTCAudioCallActivity.class);
//需要Intent.FLAG_ACTIVITY_NEW_TASK,不然会崩溃
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
});
}
@Override
public boolean onTouch(View v, MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
isMove = false;
mTouchStartX = (int) event.getRawX();
mTouchStartY = (int) event.getRawY();
mStartX = (int) event.getX();
mStartY = (int) event.getY();
break;
case MotionEvent.ACTION_MOVE:
mTouchCurrentX = (int) event.getRawX();
mTouchCurrentY = (int) event.getRawY();
wmParams.x -= mTouchCurrentX - mTouchStartX;
wmParams.y += mTouchCurrentY - mTouchStartY;
Log.i("Tim_FloatingListener", " Cx: " + mTouchCurrentX + " Sx: " + mTouchStartX + " Cy: " + mTouchCurrentY + " Sy: " + mTouchStartY);
if (mFloatingLayout != null) {
mWindowManager.updateViewLayout(mFloatingLayout, wmParams);
}
mTouchStartX = mTouchCurrentX;
mTouchStartY = mTouchCurrentY;
break;
case MotionEvent.ACTION_UP:
mStopX = (int) event.getX();
mStopY = (int) event.getY();
if (Math.abs(mStartX - mStopX) >= 1 || Math.abs(mStartY - mStopY) >= 1) {
isMove = true;
}
break;
default:
break;
}
//如果是移动事件不触发OnClick事件,防止移动的时候一放手形成点击事件
return isMove;
}
}
1、SingleInstance的 Home键处理
当luncherModel为SingleInstance的时候,点击Home键会引发很多问题
2、当应用退到后台的时候,部分手机无法唤起后台弹出(小米手机)功能,而有些手机又会直接弹出,显然这两种都不友好。
我们在接电话的地方设置一个30s的计时器,在这30s中不停检测应用是否在前台运行,并且判断通话是否结束,如果检测过程中两个条件都满足了,我们就打开通话页面,然后取消计时。这样做有两个好处,一个是,无法唤起后台弹出的手机,当我们打开app的收,在有效期之内还能接到电话。另外一个是,能后台自动弹出的手机,不会突兀的响铃和乱跳转页面。
3、离线打电话消息接收问题
腾讯的离线推送没有统一的处理,这使得我们监听离线消息变得十分困难,并且有些手机的离线推送甚至不能被检测到。这个时候我们换一种思路,我们直接在打开app的时候检测消息列表的历史消息,获取最后一条消息,进行语音通话的消息处理,这样们在接收离线通知的情况下,也能直接打开到通话页面
使用腾讯IM和腾讯实时音视频 的坑很多,不过都被我们一一淌过来了,如果你遇到不好解决的问题,欢迎留言交流,最后,如果这篇对你有一丁点帮助,请点个赞再走吧,谢谢。