概述 来电秀是当来电的时候,将系统默认的来电UI替换成我们自定义的来电页面,然后实现接听和挂断的功能。目前可以使用如下两种方式:
一种是参考大部分市面上来电秀APP的做法:监听手机的来电状态,然后以悬浮窗的方式弹出我们自定义的来电页面,覆盖系统来电页面,然后通过相关API实现接听和挂断功能。但是由于接听和挂断相关的API在Android的不同版本上都有修改,因此在某些Android版本上可能会有接听/挂断失败的情况。针对失效的情况,可以使用一些系统API以外的方式来接听/挂断电话。目前测试有效的方式有两种:
一种是通过读取来电时系统的Notification信息,然后对通知上的“接听”和“挂断”进行调用来实现挂断/接听电话的需求。
一种是模拟耳机线控的方式进行挂断/接听操作,通过操作MediaController,这种方式的成功率也比较高。
一种是将我们的应用设置成系统默认的拨号APP,但是为了避免重写通话相关的功能,可以仅在监听到来电的时候使用APP自定义的页面,其余情况我们可以重定向到系统默认的拨号应用内,这种方式比较得不偿失,仅仅为了一个来电秀便修改系统默认的拨号应用,也可能会有一些兼容性问题。
本文将对这几种方式都进行尝试(由于市面上Android 7以下的手机已经很少见了,因此只考虑了Android 7到Android 9之间的设备,Android 10暂时不考虑)。
悬浮窗实现 概述 悬浮窗实现的方式是市面上大部分来电秀应用的做法,它主要通过监听手机的来电状态,然后以悬浮窗的方式弹出我们自定义的来电页面,覆盖掉系统页面,然后通过相关API实现接听和挂断功能。
权限 首先列举一下这种实现方式需要用到的相关权限:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <uses-permission android:name ="android.permission.READ_CALL_LOG" /> <uses-permission android:name ="android.permission.READ_CONTACTS" /> <uses-permission android:name ="android.permission.READ_PHONE_STATE" /> <uses-permission android:name ="android.permission.CALL_PHONE" /> <uses-permission android:name ="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name ="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name ="android.permission.FOREGROUND_SERVICE" />
READ_CALL_LOG:Added in API level 16,权限级别:dangerous
READ_CONTACTS:Added in API level 1,权限级别:dangerous
READ_PHONE_STATE:Added in API level 1,权限级别:dangerous
CALL_PHONE:Added in API level 1,权限级别:dangerous
ANSWER_PHONE_CALLS:Added in API level 26,权限级别:dangerous
SYSTEM_ALERT_WINDOW:Added in API level 1,权限级别:signature|preinstalled|appop|pre23|development
FOREGROUND_SERVICE:Added in API level 28,权限级别:normal
其中动态申请权限代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 public class MainActivity extends AppCompatActivity { private String[] mPermissions = new String[]{ Manifest.permission.READ_CALL_LOG, Manifest.permission.READ_CONTACTS, Manifest.permission.READ_PHONE_STATE, Manifest.permission.CALL_PHONE, "" , }; private static final int REQUEST_ID_POPUP = 0 ; private static final int REQUEST_ID_PERMISSION = 1 ; @Override protected void onCreate (Bundle savedInstanceState) { super .onCreate(savedInstanceState); setContentView(R.layout.activity_main); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { mPermissions[4 ] = Manifest.permission.ANSWER_PHONE_CALLS; } getPermissions(); } private void getPermissions () { if (!Settings.canDrawOverlays(this )) { new AlertDialog.Builder(this ) .setTitle("获取悬浮窗权限" ) .setMessage("点击跳转到悬浮窗权限页面" ) .setPositiveButton("确认" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); startActivityForResult(intent, REQUEST_ID_POPUP); } }) .setNegativeButton("取消" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { MainActivity.this .finish(); } }) .show(); } else { if (checkPermission()) { startService(new Intent(this , PhoneListenService.class)); } else { ActivityCompat.requestPermissions(this , mPermissions, REQUEST_ID_PERMISSION); } } } private boolean checkPermission () { boolean hasPermission = true ; for (String permission : mPermissions) { if (!TextUtils.isEmpty(permission) && PermissionChecker.checkSelfPermission(this , permission) != PackageManager.PERMISSION_GRANTED) { hasPermission = false ; } } return hasPermission; } @Override protected void onActivityResult (int requestCode, int resultCode, @Nullable Intent data) { if (requestCode == REQUEST_ID_POPUP) { getPermissions(); } } @Override public void onRequestPermissionsResult (int requestCode, @NonNull String[] permissions, @NonNull int [] grantResults) { if (requestCode == REQUEST_ID_PERMISSION && grantResults.length > 0 ) { for (int i = 0 ; i < grantResults.length; i++) { if (grantResults[i] == PackageManager.PERMISSION_DENIED) { finish(); } } startService(new Intent(this , PhoneListenService.class)); } } }
悬浮窗 悬浮窗页面控件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 public class FloatingView extends FrameLayout { private View mView; private FloatingManager mWindowManager; private OnCallListener mListener; private boolean mShown = false ; public FloatingView (Context context) { super (context); mView = LayoutInflater.from(context).inflate(R.layout.floating_view, null ); mView.findViewById(R.id.get_call).setOnClickListener(new OnClickListener() { @Override public void onClick (View v) { hide(); if (mListener != null ) { mListener.onGet(); } } }); mView.findViewById(R.id.end_call).setOnClickListener(new OnClickListener() { @Override public void onClick (View v) { hide(); if (mListener != null ) { mListener.onEnd(); } } }); mWindowManager = FloatingManager.getInstance(context); } public void setPerson (String name, String number) { if (!TextUtils.isEmpty(name)) { ((TextView) mView.findViewById(R.id.name_tv)).setText(name); } if (!TextUtils.isEmpty(number)) { ((TextView) mView.findViewById(R.id.number_tv)).setText(number); } } public void setListener (OnCallListener listener) { this .mListener = listener; } public void show () { WindowManager.LayoutParams params = new WindowManager.LayoutParams(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { params.type = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } else { params.type = WindowManager.LayoutParams.TYPE_SYSTEM_OVERLAY; } params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED; params.width = LayoutParams.MATCH_PARENT; params.height = LayoutParams.MATCH_PARENT; mWindowManager.addView(mView, params); mShown = true ; } public void hide () { if (mShown) { mWindowManager.removeView(mView); mShown = false ; } } public interface OnCallListener { void onGet () ; void onEnd () ; } }
xml文件如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 <?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android ="http://schemas.android.com/apk/res/android" xmlns:app ="http://schemas.android.com/apk/res-auto" android:layout_width ="match_parent" android:layout_height ="match_parent" android:background ="@drawable/bg" > <TextView android:id ="@+id/name_tv" android:layout_width ="match_parent" android:layout_height ="wrap_content" android:layout_alignParentTop ="true" android:layout_marginTop ="50dp" android:gravity ="center" android:text ="陌生人" android:textColor ="#AA99AA" android:textSize ="35sp" android:textStyle ="bold" /> <TextView android:id ="@+id/number_tv" android:layout_width ="match_parent" android:layout_height ="wrap_content" android:layout_below ="@+id/name_tv" android:layout_marginTop ="20dp" android:gravity ="center" android:textColor ="#000000" android:textSize ="30sp" android:textStyle ="bold" /> <RelativeLayout android:layout_width ="match_parent" android:layout_height ="wrap_content" android:layout_alignParentBottom ="true" android:layout_marginBottom ="80dp" > <com.hearing.calltest.widget.CircleTextView android:id ="@+id/end_call" android:layout_width ="200px" android:layout_height ="200px" android:layout_alignParentStart ="true" android:layout_marginStart ="80dp" app:color ="#FF0000" app:radius ="100" app:size ="70" app:text ="挂断" /> <com.hearing.calltest.widget.CircleTextView android:id ="@+id/get_call" android:layout_width ="200px" android:layout_height ="200px" android:layout_alignParentEnd ="true" android:layout_marginEnd ="80dp" app:color ="#0000FF" app:radius ="100" app:size ="70" app:text ="接听" /> </RelativeLayout > </RelativeLayout >
渐变背景xml如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <?xml version="1.0" encoding="utf-8"?> <shape xmlns:android ="http://schemas.android.com/apk/res/android" android:shape ="rectangle" > <gradient android:angle ="45" android:centerColor ="#ffffff" android:endColor ="#AAAAEE" android:startColor ="#1EE0FF" /> <stroke android:width ="2dp" android:color ="#AA82EE" android:dashWidth ="5dp" android:dashGap ="0dp" /> <corners android:bottomLeftRadius ="5dp" android:bottomRightRadius ="5dp" android:radius ="5dp" android:topLeftRadius ="5dp" android:topRightRadius ="5dp" /> </shape >
其中有一个圆形的接听/挂断按钮控件实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 public class CircleTextView extends View { private String mCustomText; private int mCustomColor; private int mCustomRadius; private int mFontSize; private Paint mCirclePaint; private TextPaint mTextPaint; public CircleTextView (Context context) { this (context, null ); } public CircleTextView (Context context, AttributeSet attrs) { this (context, attrs, 0 ); } public CircleTextView (Context context, AttributeSet attrs, int defStyleAttr) { super (context, attrs, defStyleAttr); initCustomAttrs(context, attrs); } private void initCustomAttrs (Context context, AttributeSet attrs) { TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.CircleTextView); mFontSize = ta.getInteger(R.styleable.CircleTextView_size, 16 ); mCustomText = ta.getString(R.styleable.CircleTextView_text); mCustomColor = ta.getColor(R.styleable.CircleTextView_color, Color.BLUE); mCustomRadius = ta.getInteger(R.styleable.CircleTextView_radius, 30 ); ta.recycle(); mCirclePaint = new Paint(); mCirclePaint.setColor(mCustomColor); mCirclePaint.setStyle(Paint.Style.STROKE); mCirclePaint.setStrokeWidth(5 ); mTextPaint = new TextPaint(); mTextPaint.setColor(mCustomColor); mTextPaint.setTextSize(mFontSize); } @Override protected void onMeasure (int widthMeasureSpec, int heightMeasureSpec) { super .onMeasure(widthMeasureSpec, heightMeasureSpec); int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec); int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec); int minSize = 400 ; if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT && getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) { setMeasuredDimension(minSize, minSize); } else if (getLayoutParams().width == ViewGroup.LayoutParams.WRAP_CONTENT) { setMeasuredDimension(minSize, heightSpecSize); } else if (getLayoutParams().height == ViewGroup.LayoutParams.WRAP_CONTENT) { setMeasuredDimension(widthSpecSize, minSize); } } @Override protected void onDraw (Canvas canvas) { super .onDraw(canvas); final int paddingLeft = getPaddingLeft(); final int paddingTop = getPaddingTop(); final int paddingRight = getPaddingRight(); final int paddingBottom = getPaddingBottom(); int width = 2 * mCustomRadius - paddingLeft - paddingRight; int height = 2 * mCustomRadius - paddingTop - paddingBottom; mCustomRadius = Math.min(width, height) / 2 ; canvas.drawCircle(mCustomRadius, mCustomRadius, mCustomRadius, mCirclePaint); canvas.translate(width / 2f , height / 2f ); float textWidth = mTextPaint.measureText(mCustomText); float baseLineY = Math.abs(mTextPaint.ascent() + mTextPaint.descent()) / 2 ; canvas.drawText(mCustomText, -textWidth / 2 , baseLineY, mTextPaint); } public void setCustomText (String customText) { this .mCustomText = customText; invalidate(); } public void setCustomColor (int customColor) { this .mCustomColor = customColor; mCirclePaint.setColor(customColor); invalidate(); } public void setFontSize (int fontSize) { this .mFontSize = fontSize; mTextPaint.setTextSize(fontSize); invalidate(); } public void setCustomRadius (int customRadius) { this .mCustomRadius = customRadius; invalidate(); } }
控件的自定义属性文件如下:
1 2 3 4 5 6 7 8 9 <?xml version="1.0" encoding="utf-8"?> <resources > <declare-styleable name ="CircleTextView" > <attr name ="size" format ="integer" /> <attr name ="color" format ="color" /> <attr name ="text" format ="string" /> <attr name ="radius" format ="integer" /> </declare-styleable > </resources >
悬浮窗的管理类如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 public class FloatingManager { private WindowManager mWindowManager; private static FloatingManager mInstance; private Context mContext; public static FloatingManager getInstance (Context context) { if (mInstance == null ) { mInstance = new FloatingManager(context); } return mInstance; } private FloatingManager (Context context) { mContext = context; mWindowManager = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE); } public boolean addView (View view, WindowManager.LayoutParams params) { try { mWindowManager.addView(view, params); return true ; } catch (Exception e) { e.printStackTrace(); } return false ; } public boolean removeView (View view) { try { mWindowManager.removeView(view); return true ; } catch (Exception e) { e.printStackTrace(); } return false ; } public boolean updateView (View view, WindowManager.LayoutParams params) { try { mWindowManager.updateViewLayout(view, params); return true ; } catch (Exception e) { e.printStackTrace(); } return false ; } }
前台Service 由于要让应用在后台时依旧监听来电状态,且不轻易被系统回收掉,因此使用前台Service的方式让我们的来电秀功能运行在前台Service中(可以选择是否需要在一个独立的进程里)。
Manifest配置如下:
1 2 3 4 5 6 7 <service android:name =".service.PhoneListenService" android:process =":PhoneListenService" > <intent-filter > <action android:name ="android.intent.action.PHONE_STATE" /> </intent-filter > </service >
在前台Service中开启了一个Notification,相关代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 public class PhoneListenService extends Service { public static final String TAG = "LLL" ; private FloatingView mFloatingView; private TelecomManager mTelManager; @Override public void onCreate () { super .onCreate(); mTelManager = (TelecomManager) getSystemService(Context.TELECOM_SERVICE); mFloatingView = new FloatingView(this ); mFloatingView.setListener(new FloatingView.OnCallListener() { @Override public void onGet () { acceptCall(); } @Override public void onEnd () { endCall(); } }); } @Override public int onStartCommand (Intent intent, int flags, int startId) { Notification notification = buildNotification(); if (notification != null ) { startForeground(1 , notification); } registerPhoneStateListener(); return super .onStartCommand(intent, flags, startId); } private Notification buildNotification () { Notification notification; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (notificationManager == null ) { return null ; } String channelId = getString(R.string.app_name); NotificationChannel notificationChannel = new NotificationChannel(channelId, channelId, NotificationManager.IMPORTANCE_DEFAULT); notificationChannel.setDescription(channelId); notificationChannel.setSound(null , null ); notificationManager.createNotificationChannel(notificationChannel); notification = new Notification.Builder(this , channelId) .setContentTitle(getString(R.string.app_name)) .setContentText("来电秀" ) .setSmallIcon(R.mipmap.ic_launcher) .build(); } else { notification = new Notification.Builder(this ) .setContentTitle(getString(R.string.app_name)) .setContentText("来电秀" ) .setSmallIcon(R.mipmap.ic_launcher) .build(); } return notification; } @Nullable @Override public IBinder onBind (Intent intent) { return null ; } }
监听来电 PhoneStateListener 一种方式是通过PhoneStateListener来监听来电状态,可以通过TelephonyManager服务来监听系统通话的相关状态,共有三个状态:
CALL_STATE_IDLE:无任何状态时
CALL_STATE_OFFHOOK:接起电话时
CALL_STATE_RINGING:电话进来时
相关代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 private void registerPhoneStateListener () { TelephonyManager tm = (TelephonyManager) getSystemService(Context.TELEPHONY_SERVICE); if (tm != null ) { try { MyPhoneCallListener listener = new MyPhoneCallListener(); listener.setListener(new MyPhoneCallListener.OnCallStateChanged() { @Override public void onCallStateChanged (int state, String number) { switch (state) { case TelephonyManager.CALL_STATE_IDLE: Log.d(TAG, "无状态..." ); mFloatingView.hide(); break ; case TelephonyManager.CALL_STATE_OFFHOOK: Log.d(TAG, "正在通话..." ); mFloatingView.hide(); break ; case TelephonyManager.CALL_STATE_RINGING: Log.d(TAG, "电话响铃..." ); mFloatingView.show(); mFloatingView.setPerson(ContractsUtil.getContactName(PhoneListenService.this , number), number); break ; } } }); tm.listen(listener, PhoneStateListener.LISTEN_CALL_STATE); } catch (Exception e) { e.printStackTrace(); } } }
监听类如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 public class MyPhoneCallListener extends PhoneStateListener { private OnCallStateChanged mListener; @Override public void onCallStateChanged (int state, String incomingNumber) { Log.d("LLL" , "state = " + state + ", incomingNumber = " + incomingNumber); if (mListener != null ) { mListener.onCallStateChanged(state, incomingNumber); } super .onCallStateChanged(state, incomingNumber); } public void setListener (OnCallStateChanged listener) { this .mListener = listener; } public interface OnCallStateChanged { void onCallStateChanged (int state, String number) ; } }
BroadcastReceiver 通过PhoneStateListener的方式经过测试似乎需要让Service处于一个独立的进程中,而使用广播监听来电状态不需要如此。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 private BroadcastReceiver mPhoneStateReceiver = new BroadcastReceiver() { @Override public void onReceive (Context context, Intent intent) { String action = intent.getAction(); if (TextUtils.equals(action, "android.intent.action.PHONE_STATE" )) { String state = intent.getStringExtra("state" ); String number = intent.getStringExtra("incoming_number" ); Log.d(TAG, "state = " + state + ", number = " + number); if (TelephonyManager.EXTRA_STATE_RINGING.equalsIgnoreCase(state)) { mFloatingView.show(); mFloatingView.setPerson(ContractsUtil.getContactName(PhoneListenService.this , number), number); } } } };
注册广播:
1 2 3 IntentFilter filter = new IntentFilter(); filter.addAction("android.intent.action.PHONE_STATE" ); registerReceiver(mPhoneStateReceiver, filter);
获取联系人 根据来电号码查询联系人的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 public class ContractsUtil { public static String getContactName (Context context, String number) { if (TextUtils.isEmpty(number)) { return null ; } final ContentResolver resolver = context.getContentResolver(); Uri lookupUri; String[] projection = new String[]{ContactsContract.PhoneLookup._ID, ContactsContract.PhoneLookup.DISPLAY_NAME}; Cursor cursor = null ; try { lookupUri = Uri.withAppendedPath(ContactsContract.PhoneLookup.CONTENT_FILTER_URI, Uri.encode(number)); cursor = resolver.query(lookupUri, projection, null , null , null ); } catch (Exception ex) { ex.printStackTrace(); try { lookupUri = Uri.withAppendedPath(android.provider.Contacts.Phones.CONTENT_FILTER_URL, Uri.encode(number)); cursor = resolver.query(lookupUri, projection, null , null , null ); } catch (Exception e) { e.printStackTrace(); } } String ret = "未知来电" ; if (cursor != null && cursor.getCount() > 0 && cursor.moveToFirst()) { ret = cursor.getString(1 ); cursor.close(); } return ret; } }
挂断通话 在Android 9 平台,可以直接调用TelecomManager服务的endCall方法实现挂断电话,这个方法的相关描述如下:
在API 28 中添加,在API 29 被标记为过期
结束设备上的呼叫
需要Manifest.permission.ANSWER_PHONE_CALLS权限
在Android 9以下的平台,可以通过反射获取到TELEPHONY_SERVICE服务,然后通过AIDL IPC的方式去调用endCall方法。
AIDL接口如下:
1 2 3 4 5 6 7 8 package com.android.internal.telephony;interface ITelephony { boolean endCall () ; void answerRingingCall () ; }
在Android 8 及以下的设备中,有的手机调用endCall方法需要MODIFY_PHONE_STATE权限,然而这个权限现在只有系统应用才能申请,因此在某些手机上会挂不掉电话。
完整调用代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 private void endCall () { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { if (mTelManager != null ) { mTelManager.endCall(); } } else { try { Method method = Class.forName("android.os.ServiceManager" ).getMethod("getService" , String.class); IBinder binder = (IBinder) method.invoke(null , new Object[]{Context.TELEPHONY_SERVICE}); ITelephony telephony = ITelephony.Stub.asInterface(binder); telephony.endCall(); } catch (Exception e) { if (mTelManager != null ) { mTelManager.showInCallScreen(false ); } e.printStackTrace(); } } }
接听来电 在Android 4以下的设备上也可以跟挂断电话一样,IPC调用answerRingingCall方法即可接听来电,可惜在4.1以上的版本中,Google给这个方法的调用设置了权限,如果不是系统应用,会收到permissDeny的异常。在网上查到的很多解决办法都是模拟耳机线控的方式实现接听电话,然而在Android 7 上的几个设备上实践时,发现都失败了。
因此退而求其次,在Android 7 及以下的设备中,直接调起系统通话界面进行操作。
接听来电的完整代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void acceptCall () { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (mTelManager != null ) { if (checkSelfPermission(Manifest.permission.ANSWER_PHONE_CALLS) != PackageManager.PERMISSION_GRANTED) { return ; } mTelManager.acceptRingingCall(); } } else { if (mTelManager != null ) { mTelManager.showInCallScreen(false ); } } }
Notification 可以通过读取来电时通知栏的Notification信息来区分是否是来电通知,通过Notification上的相关字段来区分接听/挂断操作,进而模拟其点击的Action实现接听和挂断操作。
权限 需要引导用户开启通知监听权限,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public boolean isNotificationEnabled (Context context) { Set<String> packageNames = NotificationManagerCompat.getEnabledListenerPackages(this ); if (packageNames.contains(context.getPackageName())) { return true ; } return false ; } public void openNotificationListenSettings () { try { Intent intent; intent = new Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS); startActivityForResult(intent, REQUEST_ID_NOTIFICATION); } catch (Exception e) { e.printStackTrace(); } }
接听/挂断电话 监听Notification需要继承NotificationListenerService类,通常实现以下方法:
onListenerConnected()
onNotificationPosted(StatusBarNotification sbn)
onNotificationRemoved(StatusBarNotification sbn)
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 public class NotificationListenService extends NotificationListenerService { public static final String TAG = "LLL" ; @Override public void onNotificationPosted (StatusBarNotification sbn) { Log.d(TAG, "onNotificationPosted" ); super .onNotificationPosted(sbn); try { if (sbn.getNotification().actions != null ) { for (Notification.Action action : sbn.getNotification().actions) { if ("Answer" .equalsIgnoreCase(action.title.toString()) || "接听" .equalsIgnoreCase(action.title.toString())) { Log.d(TAG, "answer is true" ); PhoneHelper.getInstance().setAnswerIntent(action.actionIntent); } if ("Dismiss" .equalsIgnoreCase(action.title.toString()) || "忽略" .equalsIgnoreCase(action.title.toString())) { Log.d(TAG, "dismiss is true" ); PhoneHelper.getInstance().setEndIntent(action.actionIntent); } } } } catch (Exception e) { e.printStackTrace(); } } }
Manifest配置如下
1 2 3 4 5 6 7 <service android:name =".service.NotificationListenService" android:permission ="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" > <intent-filter > <action android:name ="android.service.notification.NotificationListenerService" /> </intent-filter > </service >
这种方式通过MediaController模拟耳机线控操作,可以在大多数设备上实现接听和挂断操作。
权限 这种方式也需要引导用户开启通知监听权限,代码如上节所示。
接听/挂断电话 需要实现一个空的NotificationListenerService类,Manifest配置如上节所示。
1 2 3 public class EmptyNotificationListenService extends NotificationListenerService {}
一般来说挂断/接听电话使用的是如下两种线控方式:
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 private void sendHeadsetHook (boolean isAnswer) { MediaSessionManager sessionManager = (MediaSessionManager) getSystemService(Context.MEDIA_SESSION_SERVICE); if (sessionManager == null ) { return ; } try { List<MediaController> controllers = sessionManager.getActiveSessions(new ComponentName(this , EmptyNotificationListenService.class)); for (MediaController m : controllers) { if ("com.android.server.telecom" .equals(m.getPackageName())) { if (isAnswer) { m.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK)); } else { long now = SystemClock.uptimeMillis(); m.dispatchMediaButtonEvent(new KeyEvent(now, now, KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_HEADSETHOOK, 1 , 0 , KeyCharacterMap.VIRTUAL_KEYBOARD, 0 , KeyEvent.FLAG_LONG_PRESS, InputDevice.SOURCE_KEYBOARD)); } m.dispatchMediaButtonEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_HEADSETHOOK)); Log.d(TAG, "headset sent to tel" ); break ; } } } catch (SecurityException e) { Log.d(TAG, "Permission error, Access to notification not granted to the app." ); } }
锁屏显示 锁屏权限需要引导用户手动开启(某些机型没有这个权限?),代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 if (!PermissionUtil.getInstance().isLockOpen(this )) { new AlertDialog.Builder(this ) .setMessage("请开启锁屏显示权限" ) .setPositiveButton("取消" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { dialog.dismiss(); } }) .setNegativeButton("确认" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { PermissionUtil.getInstance().setLockOpen(MainActivity.this ); Intent intent = new Intent(); intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); intent.setData(Uri.fromParts("package" , getPackageName(), null )); startActivity(intent); } }) .create() .show(); }
自启动 为了防止用户在退出应用后,来电秀失效,可以将上述的PhoneListenService和EmptyNotificationListenService放在一个单独的进程中,在某些机型(VIVO)上即使退出了应用,该Service依旧存活。而在更多的机型(小米,华为等),service所在进程也会被杀死。在这些机型上,可以引导用户开启自启动权限,在开启了自启动后,即使用户杀掉了应用主进程,service也会不定时重启,可能是立刻,也可能是几秒。
可以在Service中的回调函数中打印日志,针对不同的机型,应用死亡后会回调的方法也不一样,需要适配一下。在会被回调的方法中,可以重新启动MainActivity,也可以做其它的事情。
将PhoneListenService和EmptyNotificationListenService放在一个单独的进程中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <service android:name =".service.PhoneListenService" android:enabled ="true" android:exported ="false" android:process =":phone" > <intent-filter > <action android:name ="android.intent.action.PHONE_STATE" /> </intent-filter > </service > <service android:name =".service.EmptyNotificationListenService" android:enabled ="true" android:exported ="false" android:process =":phone" android:permission ="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" > <intent-filter > <action android:name ="android.service.notification.NotificationListenerService" /> </intent-filter > </service >
引导用户开启自启动的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 if (!PermissionUtil.getInstance().isLaunchOpen(this )) { new AlertDialog.Builder(this ) .setMessage("请开启自启动权限" ) .setPositiveButton("取消" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { dialog.dismiss(); } }) .setNegativeButton("确认" , new DialogInterface.OnClickListener() { @Override public void onClick (DialogInterface dialog, int which) { PermissionUtil.getInstance().setLaunchOpen(MainActivity.this ); try { startActivity(getAutostartSettingIntent(MainActivity.this )); } catch (Exception e) { e.printStackTrace(); } } }) .create() .show(); }
默认拨号应用实现 概述 这种方式是通过引导用户将我们的应用设置成系统默认拨号应用,这种方式基本上不会存在挂断和接听失效的情况,所需的权限也比较少,然而可能存在一些兼容性问题,如果出现,用户体验会比较差。
最理想的方式是将系统拨号应用的功能全部实现,可惜工作量比较大,因此可以只在监听到来电的时候才调用我们自定义的功能,其余情况分状态跳转到系统自带的页面。
在我自己的测试中,实现上还有一些问题,由于觉得这种方式依旧太重量级了,因此没有花太多时间去研究…
权限 1 2 3 4 <uses-permission android:name ="android.permission.CALL_PHONE" /> <uses-permission android:name ="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name ="android.permission.FOREGROUND_SERVICE" />
动态申请权限的代码如上节所示。
悬浮窗 这一块的功能基本不变,实现上也是一样的。
申请默认应用 1 2 3 4 5 6 7 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (!TextUtils.equals(((TelecomManager) getSystemService(Context.TELECOM_SERVICE)).getDefaultDialerPackage(), getPackageName())) { startActivity(new Intent(ACTION_CHANGE_DEFAULT_DIALER).putExtra(EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, getPackageName())); } } }
InCallService 这个功能的核心类就是InCallService类,其相关用法直接参考官网说明 ,不过在RoleManager这部分的说明中,由于RoleManager 是在API 29(Android 10)才添加的,因此在Android Q以下的设备中不能使用这种方式申请成为默认应用,具体申请方法如上节所述。
参考官网的做法,首先在Manifest文件中声明MainActivity具有处理通话的能力,然后声明InCallService自定义的功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 <activity android:name =".MainActivity" > <intent-filter > <action android:name ="android.intent.action.MAIN" /> <category android:name ="android.intent.category.LAUNCHER" /> </intent-filter > <intent-filter > <action android:name ="android.intent.action.VIEW" /> <action android:name ="android.intent.action.DIAL" /> <category android:name ="android.intent.category.DEFAULT" /> <category android:name ="android.intent.category.BROWSABLE" /> <data android:scheme ="tel" /> </intent-filter > <intent-filter > <action android:name ="android.intent.action.DIAL" /> <category android:name ="android.intent.category.DEFAULT" /> </intent-filter > </activity > <service android:name =".service.CallService" android:permission ="android.permission.BIND_INCALL_SERVICE" > <meta-data android:name ="android.telecom.IN_CALL_SERVICE_UI" android:value ="true" /> <intent-filter > <action android:name ="android.telecom.InCallService" /> </intent-filter > </service >
其中android.telecom.IN_CALL_SERVICE_UI
的表示此InCallService将替换内置的通话中UI。
自定义的InCallService代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 public class CallService extends InCallService { public static final String TAG = "LLL" ; private Call mCall; private FloatingView mFloatingView; @Override public void onCreate () { super .onCreate(); mFloatingView = new FloatingView(this ); mFloatingView.setListener(new FloatingView.OnCallListener() { @Override public void onGet () { mFloatingView.hide(); if (mCall != null ) { mCall.answer(VideoProfile.STATE_AUDIO_ONLY); gotoDialog(); } } @Override public void onEnd () { mFloatingView.hide(); if (mCall != null ) { mCall.disconnect(); } } }); } public CallService () { super (); } @Override public IBinder onBind (Intent intent) { Log.d(TAG, "onBind" ); return super .onBind(intent); } @Override public boolean onUnbind (Intent intent) { Log.d(TAG, "onUnbind" ); return super .onUnbind(intent); } @Override public void onCallAudioStateChanged (CallAudioState audioState) { Log.d(TAG, "onCallAudioStateChanged" ); super .onCallAudioStateChanged(audioState); } @Override public void onCallRemoved (Call call) { Log.d(TAG, "onCallRemoved" ); super .onCallRemoved(call); } @Override public void onCallAdded (Call call) { Log.d(TAG, "state = " + call.getState()); mCall = call; switch (call.getState()) { case Call.STATE_RINGING: mFloatingView.show(); break ; default : break ; } } private void gotoDialog () { Intent intent = new Intent(Intent.ACTION_CALL, null ); if (checkSelfPermission(Manifest.permission.CALL_PHONE) != PackageManager.PERMISSION_GRANTED) { return ; } intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); } }
CallScreeningService 这个类是用来做来电筛选的,在APP成为默认拨号应用后,可以监听来电状态,然后执行拦截与否的操作,可以使用这个类进行挂断电话的操作,但是不能接听电话。
在Manifest文件配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 <application android:allowBackup ="true" android:icon ="@mipmap/ic_launcher" android:label ="@string/app_name" android:roundIcon ="@mipmap/ic_launcher_round" android:supportsRtl ="true" android:theme ="@style/Theme.AppCompat.Light.NoActionBar" > <activity android:name =".MainActivity" > <intent-filter > <action android:name ="android.intent.action.MAIN" /> <category android:name ="android.intent.category.LAUNCHER" /> </intent-filter > <intent-filter > <action android:name ="android.intent.action.VIEW" /> <action android:name ="android.intent.action.DIAL" /> <category android:name ="android.intent.category.DEFAULT" /> <category android:name ="android.intent.category.BROWSABLE" /> <data android:scheme ="tel" /> </intent-filter > <intent-filter > <action android:name ="android.intent.action.DIAL" /> <category android:name ="android.intent.category.DEFAULT" /> </intent-filter > </activity > <service android:name =".service.CustomCallScreeningService" android:permission ="android.permission.BIND_SCREENING_SERVICE" > <intent-filter > <action android:name ="android.telecom.CallScreeningService" /> </intent-filter > </service > </application >
自定义CallScreeningService服务如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 public class CustomCallScreeningService extends CallScreeningService { private static final String TAG = "CallScreeningService" ; private FloatingView mFloatingView; private Call.Details mCallDetails; @Override public void onCreate () { super .onCreate(); mFloatingView = new FloatingView(this ); mFloatingView.setListener(new FloatingView.OnCallListener() { @Override public void onGet () { acceptCall(); } @Override public void onEnd () { endCall(); } }); } private void acceptCall () { if (mCallDetails == null ) { return ; } Log.d(TAG, "acceptCall" ); CallResponse response = new CallResponse .Builder() .setDisallowCall(false ) .setRejectCall(false ) .setSkipCallLog(false ) .setSkipNotification(false ) .build(); respondToCall(mCallDetails, response); mCallDetails = null ; } private void endCall () { if (mCallDetails == null ) { return ; } Log.d(TAG, "endCall" ); CallResponse response = new CallResponse .Builder() .setDisallowCall(true ) .setRejectCall(true ) .setSkipCallLog(true ) .setSkipNotification(true ) .build(); respondToCall(mCallDetails, response); mCallDetails = null ; } @Override public IBinder onBind (Intent intent) { Log.d(TAG, "onBind" ); return super .onBind(intent); } @Override public boolean onUnbind (Intent intent) { Log.d(TAG, "onUnbind" ); return super .onUnbind(intent); } @Override public void onScreenCall (@NonNull Call.Details callDetails) { Log.d(TAG, "onScreenCall" ); mFloatingView.setPerson(callDetails.getCallerDisplayName(), callDetails.getCallerDisplayName()); mFloatingView.show(); mCallDetails = callDetails; } }
结论 悬浮窗实现 这种方式是将我们的应用作为一个普通的app,当监听到来电时通过悬浮窗的形式弹出我们自定义的来电页面,然后通过我们提供的挂断/接听按钮进行挂断/接听操作。
所需权限如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <uses-permission android:name ="android.permission.READ_CALL_LOG" /> <uses-permission android:name ="android.permission.READ_CONTACTS" /> <uses-permission android:name ="android.permission.READ_PHONE_STATE" /> <uses-permission android:name ="android.permission.CALL_PHONE" /> <uses-permission android:name ="android.permission.ANSWER_PHONE_CALLS" /> <uses-permission android:name ="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name ="android.permission.FOREGROUND_SERVICE" />
存在的问题:
系统提供的API中,在Android 9 上的设备接听/挂断电话都可实现
小部分Android 8 上不能通过系统API挂断电话(在Redmi 6A,Android 8.1.0 011019设备上测试失败,在其它三款Android 8的设备上测试可以成功)
Android 7 上不能通过系统API接听电话(只有一台Android 7 的测试设备,测试结果为接听失败)
解决办法:尝试过应用商店下载量比较高的几款来电秀应用,都存在这个问题,可以使用如下几个补救方案:
当尝试挂断/接听电话失败时,跳转到系统通话的页面,让用户再一次手动挂断/接听电话。
监听来电Notification挂断/接听电话
模拟耳机线控挂断/接听电话
后两种方式如下所述。
Notification 通过读取来电时通知栏的Notification信息来区分是否是来电通知,通过Notification上的相关字段来区分接听/挂断操作,进而模拟其点击的Action实现接听和挂断操作。
这种方式需要解决的问题是如何准确识别出当前通知是否是来电通知,以及对应的接听/挂断是Notification中的哪一个Action:
可以通过筛选通知的包名来识别是否为来电通知(不能型号手机包名可能不一样)
可以通过title来识别是否为接听/挂断,这个也取决于不同的手机型号,因为不同的开发商开发的拨号应用其使用的title可能不一样。更有甚者,系统在切换了语言后,title也会随之变化,想想就头大。
所需的权限:
存在的问题:
在不同的Android版本上都可以挂断/接听
准确匹配当前的Notification是否是来电通知比较麻烦
通过title的方式区分挂断/接听操作有局限性,不同手机型号上title不一致,而且也会受到语言切换的影响
这种方式的成功率经测试发现效果比较好,更准确的成功率有待进一步测试。
所需的权限:
暂时没有发现其他问题。
自启动/锁屏 都需要引导用户手动开启这两个权限,而且需要适配不同的机型,不同的机型这些权限所在的应用包名也不一样。
默认拨号应用 引导用户手动选择我们的应用作为系统默认拨号应用(很多用户应该不会允许),如果要达到理想的效果,需要我们手动实现一个拨号应用的全部功能。如果不想实现拨号应用的全部功能,只实现来电时自定义一个接听页面,则会影响到其他部分的功能,比如说拨号等,尝试了一下,发现结果不理想。
如果不介意实现一个通话应用的全部功能,那么InCallService无疑是一个理想的解决方案,因为接听和挂断电话在Call 这个类中都有实现。
所需的权限:
1 2 3 4 5 6 <uses-permission android:name ="android.permission.CALL_PHONE" /> <uses-permission android:name ="android.permission.SYSTEM_ALERT_WINDOW" /> <uses-permission android:name ="android.permission.FOREGROUND_SERVICE" />
存在的问题:
需要用户同意选择默认拨号应用,很多用户可能不会允许
接听/挂断功能正常,但可能会影响到拨号等功能
通过成为系统默认拨号应用来仅仅实现一个来电秀功能,比较重量级
总结 综上所述,使用悬浮窗实现
(系统API+MediaController)的方式可以实现来电秀的功能,通过调用系统API,在失效的设备上接着使用模拟耳机线控的方式来挂断/接听电话,经过测试暂时还没有发现失效的设备(仅限于Android 7,8,9)。
另外,在android.telecom
和android.telephony
这两个包中还有许多系统提供的api,说不定可以从中找到新的有效方案,这个也有待后续的调研。
本文的项目完整代码链接:CallTest 。