目录
[📂 前言](#📂 前言)
[AR 眼镜系统版本](#AR 眼镜系统版本)
[1. 🔱 技术方案](#1. 🔱 技术方案)
[1.1 结构框图](#1.1 结构框图)
[1.2 方案介绍](#1.2 方案介绍)
[1.3 实现方案](#1.3 实现方案)
[2. 💠 屏蔽原生蓝牙电话相关功能](#2. 💠 屏蔽原生蓝牙电话相关功能)
[2.1 蓝牙电话核心时序图](#2.1 蓝牙电话核心时序图)
[2.2 实现细节](#2.2 实现细节)
[步骤一:禁止系统拉起来去电页面 InCallActivity](#步骤一:禁止系统拉起来去电页面 InCallActivity)
[步骤二:屏蔽来电消息 Notification 显示](#步骤二:屏蔽来电消息 Notification 显示)
[3. ⚛️ 自定义蓝牙电话实现](#3. ⚛️ 自定义蓝牙电话实现)
[3.1 自定义蓝牙电话时序图](#3.1 自定义蓝牙电话时序图)
[3.2 实现细节](#3.2 实现细节)
[步骤一:注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态](#步骤一:注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态)
[4. ✅ 小结](#4. ✅ 小结)
📂 前言
AR 眼镜系统版本
W517 Android9。
蓝牙电话
主要实现 HFP 协议,主要实现拨打、接听、挂断电话(AG 侧、HF 侧)、切换声道等功能。
-
HFP(Hands-Free Profile)协议------一种蓝牙通信协议,实现 AR 眼镜与手机之间的通信;
-
AG(Audio Gate)音频网关------音频设备输入输出网关 ;
-
HF(Hands Free)免提------该设备作为音频网关的远程音频输入/输出机制,并可提供若干遥控功能。
在 AR 眼镜蓝牙中,手机侧是 AG,AR 眼镜蓝牙侧是 HF,在 Android 源代码中,将 AG 侧称为 HFP/AG,将 HF 侧称为 HFPClient/HF。
来电铃声
Andriod 来电的铃声默认保存在 system/media/audio/ 下面,有四个文件夹,分别是 alarms(闹钟)、notifications(通知)、ringtones(铃声)、ui(UI音效),源码中这些文件保存在 frameworks\base\data\sounds 目录下面。
1. 🔱 技术方案
1.1 结构框图
1.2 方案介绍
技术方案概述:由于定制化程度较高,包括 3dof/6dof 渲染效果、佩戴检测功能等,所以采取屏蔽原生蓝牙电话相关功能,使用完全自定义的蓝牙电话实现方案。
1.3 实现方案
步骤一:屏蔽原生蓝牙电话相关功能
-
禁止系统拉起来去电页面 InCallActivity;
-
屏蔽来电消息 Notification 显示;
-
替换来电铃声。
步骤二:自定义蓝牙电话实现
-
注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态;
-
开发来电弹窗、来电界面,并处理相关业务逻辑;
-
通过 BluetoothAdapter 获取并且初始化 BluetoothHeadsetClient 对象,然后就可以调用 api:dial()/acceptCall()/rejectCall()/terminateCall() 方法进行拨号/接通/拒接的操作。
2. 💠 屏蔽原生蓝牙电话相关功能
-
系统来去电页面处理类:w517\packages\apps\Dialer\java\com\android\incallui\InCallPresenter.java
-
系统来电消息通知类:w517\packages\apps\Dialer\java\com\android\incallui\StatusBarNotifier.java
-
系统来电铃声类:w517\packages\services\Telecomm\src\com\android\server\telecom\Ringer.java
-
系统来电铃声文件路径:w517\frameworks\base\data\sounds\Ring_Synth_04.ogg
2.1 蓝牙电话核心时序图
2.2 实现细节
步骤一:禁止系统拉起来去电页面 InCallActivity
步骤二:屏蔽来电消息 Notification 显示
步骤三:替换来电铃声
制作一个来电铃声的 Ring_Synth_04.ogg 文件,替换即可。
3. ⚛️ 自定义蓝牙电话实现
3.1 自定义蓝牙电话时序图
3.2 实现细节
步骤一:注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态
1、获取 BluetoothHeadsetClient 实例:
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothHeadsetClient
import android.bluetooth.BluetoothProfile
import android.content.Context
private var headsetClient: BluetoothHeadsetClient? = null
fun getHeadsetClient(context: Context): BluetoothHeadsetClient? {
if (headsetClient != null) return headsetClient
BluetoothAdapter.getDefaultAdapter().apply {
getProfileProxy(
context, object : ServiceListener {
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
headsetClient = proxy as BluetoothHeadsetClient
}
override fun onServiceDisconnected(profile: Int) {}
}, BluetoothProfile.HEADSET_CLIENT
)
}
return headsetClient
}
2、注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 :
context.registerReceiver(
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (BluetoothHeadsetClient.ACTION_CALL_CHANGED == intent.action) {
intent.getParcelableExtra<BluetoothHeadsetClientCall>(BluetoothHeadsetClient.EXTRA_CALL)
?.let { handleCallState(context, it) }
}
}
}, IntentFilter(BluetoothHeadsetClient.ACTION_CALL_CHANGED)
)
3、处理广播回调的蓝牙状态:
var isInComing = false
private var headsetClientCall: BluetoothHeadsetClientCall? = null
private var mainHandler: Handler = Handler(Looper.getMainLooper())
private var isWearing = true
fun getHeadsetClientCall() = headsetClientCall
private fun handleCallState(context: Context, call: BluetoothHeadsetClientCall) {
headsetClientCall = call
when (call.state) {
BluetoothHeadsetClientCall.CALL_STATE_ACTIVE -> {
Log.i(TAG, "Call is active:mNumber = ${call.number}")
// 佩戴检测逻辑
if (headsetClient != null) {
val isAudioConnected = headsetClient!!.getAudioState(call.device) == 2
Log.i(TAG, "isAudioConnected = $isAudioConnected,isWearing = $isWearing")
if (isWearing) {
if (!isAudioConnected) {
headsetClient!!.connectAudio(call.device)
}
} else {
if (isAudioConnected) {
headsetClient!!.disconnectAudio(call.device)
}
}
}
if (isInComing) {
isInComing = false
PhoneTalkingDialogHelper.removeDialog()
PhoneInCallDialogHelper.removeDialog()
PhoneTalkingActivity.start(context)
}
}
BluetoothHeadsetClientCall.CALL_STATE_HELD -> Log.d(TAG, "Call is held")
BluetoothHeadsetClientCall.CALL_STATE_DIALING -> Log.d(TAG, "Call is dialing")
BluetoothHeadsetClientCall.CALL_STATE_ALERTING -> Log.d(TAG, "Call is alerting")
BluetoothHeadsetClientCall.CALL_STATE_INCOMING -> {
Log.i(TAG, "Incoming call:mNumber = ${call.number}")
if (!isInComing) {
isInComing = true
PhoneTalkingDialogHelper.removeDialog()
PhoneInCallDialogHelper.removeDialog()
headsetClient?.let {
PhoneInCallDialogHelper.addDialog(context, call, it)
} ?: let {
getHeadsetClient(context)
mainHandler.post {
headsetClient?.let {
PhoneInCallDialogHelper.addDialog(context, call, it)
} ?: let {
Log.e(TAG, "Incoming call:headsetClient=null!!!")
}
}
}
}
}
BluetoothHeadsetClientCall.CALL_STATE_WAITING -> Log.d(TAG, "Call is waiting")
BluetoothHeadsetClientCall.CALL_STATE_TERMINATED -> {
Log.i(TAG, "Call is terminated")
isInComing = false
PhoneTalkingDialogHelper.terminatedCall(context, PHONE_TALKING_UI_DISMISS)
PhoneInCallDialogHelper.removeDialog(PHONE_TALKING_TIME_UPDATE)
LiveEventBus.get<Boolean>(NOTIFICATION_CALL_STATE_TERMINATED).post(true)
}
else -> Log.d(TAG, "Unknown call state: ${call.state}")
}
}
通过 BluetoothHeadsetClientCall.CALL_STATE_INCOMING 事件,触发来电弹窗 PhoneInCallDialogHelper.addDialog()。
步骤二:开发来电弹窗、来电界面,并处理相关业务逻辑
1、addDialog 显示来电弹窗:
object PhoneInCallDialogHelper {
private val TAG = PhoneInCallDialogHelper::class.java.simpleName
private var mInCallDialog: View? = null
private var mWindowManager: WindowManager? = null
private var mLayoutParams: WindowManager.LayoutParams? = null
private val mTimeOut: CountDownTimer = object : CountDownTimer(60000L, 1000) {
override fun onTick(millisUntilFinished: Long) {}
override fun onFinish() {
removeDialog()
}
}.start()
fun addDialog(
context: Context,
call: BluetoothHeadsetClientCall,
headsetClient: BluetoothHeadsetClient,
) {
ThemeUtils.setTheme(context)
removeDialog()
mInCallDialog = (LayoutInflater.from(context)
.inflate(R.layout.notification_incall_layout, null) as View).apply {
// 还未接入指环,先不显示指环动画
// val ringAnimation = findViewById<ImageView>(R.id.ringAnimation)
// ringAnimation.setImageResource(R.drawable.notification_ring_animation)
// (ringAnimation.drawable as AnimationDrawable).start()
findViewById<TextView>(R.id.title).text =
getContactNameFromPhoneBook(context, call.number)
findViewById<TextView>(R.id.content).text = call.number
}
initLayoutParams(context)
mWindowManager?.addView(mInCallDialog, mLayoutParams)
mTimeOut.cancel()
mTimeOut.start()
}
fun removeDialog(delayMillis: Long = 0) {
kotlin.runCatching {
mTimeOut.cancel()
mInCallDialog?.let {
if (it.isAttachedToWindow) {
it.postDelayed({
mWindowManager?.removeView(it)
mInCallDialog = null
}, delayMillis)
}
}
}
}
private fun initLayoutParams(context: Context) {
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
mLayoutParams = WindowManager.LayoutParams().apply {
type = WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL
gravity = Gravity.CENTER
width = (354 * context.resources.displayMetrics.density + 0.5f).toInt()
height = WindowManager.LayoutParams.WRAP_CONTENT
flags =
(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or WindowManager.LayoutParams.FLAG_TOUCHABLE_WHEN_WAKING or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
format = PixelFormat.RGBA_8888 // 去除默认时有的黑色背景,设置为全透明
dofIndex = 1 // 默认为1。 为0,则表示窗口为0DOF模式;为1,则表示窗口为3DOF模式;为2,则表示窗口为6DOF模式。
setTranslationZ(TRANSLATION_Z_150CM)
setRotationXAroundOrigin(-XrEnvironment.getInstance().headPose.roll)
setRotationYAroundOrigin(-XrEnvironment.getInstance().headPose.yaw)
setRotationZAroundOrigin(-XrEnvironment.getInstance().headPose.pitch)
title = AGG_SYSUI_INCOMING
}
}
}
2、用户点击来电弹窗窗口、拒接或接听:
findViewById<ConstraintLayout>(R.id.inCallLayout).setOnClickListener {
Log.i(TAG, "addDialog: 进入activity页面")
removeDialog()
XrEnvironment.getInstance().imuReset()
PhoneTalkingActivity.start(context)
}
findViewById<ImageView>(R.id.reject).setOnClickListener {
Log.i(TAG, "addDialog: 拒接 ${call.number}")
headsetClient.rejectCall(call.device)
SoundPoolTools.play(
context,
SoundPoolTools.RING,
com.agg.launcher.middleware.R.raw.phone_hang_up
)
removeDialog(Constants.PHONE_TALKING_TIME_UPDATE)
}
findViewById<ImageView>(R.id.answer).setOnClickListener {
Log.i(TAG, "addDialog: 接听 ${call.number}")
headsetClient.acceptCall(call.device, BluetoothHeadsetClient.CALL_ACCEPT_NONE)
PhoneNotificationHelper.isInComing = false
removeDialog()
PhoneTalkingDialogHelper.addDialog(context, call, headsetClient)
SoundPoolTools.play(
context,
SoundPoolTools.RING,
com.agg.launcher.middleware.R.raw.phone_answer
)
}
3、跳转通话中弹窗:
object PhoneTalkingDialogHelper {
private val TAG = PhoneTalkingDialogHelper::class.java.simpleName
private var mTalkingDialog: View? = null
private var mContentView: TextView? = null
private var mTerminateView: ImageView? = null
private var mWindowManager: WindowManager? = null
private var mLayoutParams: WindowManager.LayoutParams? = null
private var mTalkingTimer = Timer()
private var mCurrentTalkingTime = 0
fun addDialog(
context: Context, call: BluetoothHeadsetClientCall, headsetClient: BluetoothHeadsetClient
) {
ThemeUtils.setTheme(context)
removeDialog()
mTalkingDialog = (LayoutInflater.from(context)
.inflate(R.layout.notification_talking_layout, null) as View).apply {
findViewById<ConstraintLayout>(R.id.talkingLayout).setOnClickListener {
Log.i(TAG, "addDialog: 进入activity页面")
removeDialog()
XrEnvironment.getInstance().imuReset()
PhoneTalkingActivity.start(context, mCurrentTalkingTime)
}
findViewById<TextView>(R.id.title).text =
AppUtils.getContactNameFromPhoneBook(context, call.number)
mContentView = findViewById(R.id.content)
mTerminateView = findViewById<ImageView>(R.id.terminate).apply {
setOnClickListener {
Log.i(TAG, "addDialog: 挂断 ${call.number}")
headsetClient.terminateCall(call.device, call)
terminatedCall(context, PHONE_TALKING_TIME_UPDATE)
SoundPoolTools.play(
context,
SoundPoolTools.RING,
com.agg.launcher.middleware.R.raw.phone_hang_up
)
}
}
}
initLayoutParams(context)
mWindowManager?.addView(mTalkingDialog, mLayoutParams)
mTalkingTimer = Timer()
mCurrentTalkingTime = 0
mTalkingTimer.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
mContentView?.text = DateUtils.getTalkingTimeString(mCurrentTalkingTime++)
}
}, PHONE_TALKING_TIME_UPDATE, PHONE_TALKING_TIME_UPDATE)
}
fun removeDialog() {
kotlin.runCatching {
mTalkingDialog?.let {
if (it.isAttachedToWindow) {
mWindowManager?.removeView(it)
mTalkingDialog = null
mTalkingTimer.cancel()
}
}
}
}
fun terminatedCall(context: Context, delayMillis: Long) {
Log.i(TAG, "terminatedCall: delayMillis = $delayMillis")
mTalkingTimer.cancel()
mTerminateView?.isEnabled = false
mContentView?.text = context.getString(R.string.agg_notification_phone_finish)
mContentView?.postDelayed({ removeDialog() }, delayMillis)
}
private fun initLayoutParams(context: Context) {
mWindowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
mLayoutParams = WindowManager.LayoutParams().apply {
type = WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANEL
gravity = Gravity.CENTER
width = (354 * context.resources.displayMetrics.density + 0.5f).toInt()
height = WindowManager.LayoutParams.WRAP_CONTENT
flags =
(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN or WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR or WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH or WindowManager.LayoutParams.FLAG_TOUCHABLE_WHEN_WAKING or WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
format = PixelFormat.RGBA_8888 // 去除默认时有的黑色背景,设置为全透明
dofIndex = 1 // 默认为1。 为0,则表示窗口为0DOF模式;为1,则表示窗口为3DOF模式;为2,则表示窗口为6DOF模式。
setTranslationZ(TRANSLATION_Z_150CM)
setRotationXAroundOrigin(-XrEnvironment.getInstance().headPose.roll)
setRotationYAroundOrigin(-XrEnvironment.getInstance().headPose.yaw)
setRotationZAroundOrigin(-XrEnvironment.getInstance().headPose.pitch)
title = AGG_SYSUI_TALKING
}
}
}
4、进入通话中 Activity:
<activity
android:name=".phonenotification.activity.PhoneTalkingActivity"
android:exported="false"
android:launchMode="singleTask">
<intent-filter>
<action android:name="com.agg.launcher.action.PHONE_TALKING" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
class PhoneTalkingActivity : Activity() {
private var call: BluetoothHeadsetClientCall? = null
private var headsetClient: BluetoothHeadsetClient? = null
private lateinit var binding: NotificationActivityPhoneTalkingBinding
private var mCurrentTalkingTime = 0
private var mIsMute = false
private var mInitIsMute = false
private var mAudioManager: AudioManager? = null
private var mTalkingTimer = Timer()
companion object {
private val TAG = PhoneTalkingActivity::class.java.simpleName
private val EXTRA_CALL_TIME = "EXTRA_CALL_TIME"
fun start(context: Context, time: Int = 0) {
try {
val intent = Intent("com.agg.launcher.action.PHONE_TALKING")
intent.`package` = context.packageName
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.putExtra(EXTRA_CALL_TIME, time)
context.startActivity(intent)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
Log.i(TAG, "onCreate: ")
super.onCreate(savedInstanceState)
ThemeUtils.setTheme(this)
binding = NotificationActivityPhoneTalkingBinding.inflate(layoutInflater)
setContentView(binding.root)
initAudio()
initPhoneData()
initView()
initInfo()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
Log.i(TAG, "onNewIntent: ")
call = PhoneNotificationHelper.getHeadsetClientCall()
if (mCurrentTalkingTime <= 0) {
mCurrentTalkingTime = intent.getIntExtra(EXTRA_CALL_TIME, 0)
}
}
override fun onResume() {
super.onResume()
if (call != null) {
Log.i(TAG, "onResume: CALL_STATE_ACTIVE = ${call!!.state == CALL_STATE_ACTIVE}")
if (call!!.state == CALL_STATE_ACTIVE) {
initAnswerView()
}
}
}
override fun onDestroy() {
super.onDestroy()
mAudioManager?.isMicrophoneMute = mInitIsMute
Log.i(TAG, "onDestroy: mInitIsMute = $mInitIsMute,mIsMute = $mIsMute")
}
private fun initAudio() {
mAudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
mInitIsMute = mAudioManager?.isMicrophoneMute == true
mIsMute = mInitIsMute
Log.i(TAG, "initAudio: mInitIsMute = $mInitIsMute,mIsMute = $mIsMute")
}
private fun initPhoneData() {
headsetClient = PhoneNotificationHelper.getHeadsetClient(this)
if (headsetClient == null) {
Log.i(TAG, "initBluetoothHeadsetClient: headsetClient = null")
binding.root.post {
headsetClient = PhoneNotificationHelper.getHeadsetClient(this)
Log.i(TAG, "initBluetoothHeadsetClient: ${headsetClient == null}")
}
}
call = PhoneNotificationHelper.getHeadsetClientCall()
mCurrentTalkingTime = intent.getIntExtra(EXTRA_CALL_TIME, 0)
}
private fun initView() {
// 还未接入指环,先不显示指环动画
// val ringAnimationLayout = findViewById<FrameLayout>(R.id.ringAnimationLayout)
// val ringAnimation = findViewById<ImageView>(R.id.ringAnimation)
// ringAnimation.setImageResource(R.drawable.notification_ring_animation)
// (ringAnimation.drawable as AnimationDrawable).start()
binding.hangup.setOnClickListener {
// 拒接
if (call != null) {
headsetClient?.rejectCall(call!!.device)
}
SoundPoolTools.play(
this, SoundPoolTools.RING, com.agg.launcher.middleware.R.raw.phone_hang_up
)
terminatedCall(PHONE_TALKING_TIME_UPDATE)
}
binding.answer.setOnClickListener {
// 接听
if (call != null) {
headsetClient?.acceptCall(call!!.device, BluetoothHeadsetClient.CALL_ACCEPT_NONE)
}
initAnswerView()
SoundPoolTools.play(
this, SoundPoolTools.RING, com.agg.launcher.middleware.R.raw.phone_answer
)
}
findViewById<ImageView>(R.id.more).setOnClickListener {
AGGDialog.Builder(this)
.setIcon(resources.getDrawable(R.drawable.notification_ic_phone_subtitles))
.setContent(resources.getString(R.string.agg_notification_phone_subtitles))
.setLeftButton(resources.getString(R.string.agg_notification_cancel),
object : AGGDialog.OnClickListener {
override fun onClick(dialog: Dialog) {
dialog.dismiss()
}
}).show()
AGGToast(
this, Toast.LENGTH_SHORT, resources.getString(R.string.agg_notification_not_open_yet)
).show()
}
LiveEventBus.get(LiveEventBusKey.NOTIFICATION_CALL_STATE_TERMINATED, Boolean::class.java)
.observeForever { terminatedCall(PHONE_TALKING_UI_DISMISS) }
}
private fun initInfo() {
call?.let {
findViewById<TextView>(R.id.title).text =
AppUtils.getContactNameFromPhoneBook(this, it.number)
findViewById<TextView>(R.id.content).text = it.number
}
}
private fun initAnswerView() {
binding.answer.visibility = View.GONE
binding.hangup.visibility = View.GONE
// 还未接入指环,先不显示指环动画
// ringAnimationLayout.visibility = View.GONE
binding.hangupBig.visibility = View.VISIBLE
binding.hangupBig.setOnClickListener {
// 挂断
if (call != null) {
headsetClient?.terminateCall(call!!.device, call)
}
SoundPoolTools.play(
this, SoundPoolTools.RING, com.agg.launcher.middleware.R.raw.phone_hang_up
)
terminatedCall(PHONE_TALKING_TIME_UPDATE)
}
binding.mute.visibility = View.VISIBLE
binding.mute.setOnClickListener {
if (mIsMute) {
mIsMute = false
binding.mute.setImageResource(R.drawable.notification_mute_close)
} else {
mIsMute = true
binding.mute.setImageResource(R.drawable.notification_mute_open)
AGGToast(
this@PhoneTalkingActivity,
Toast.LENGTH_SHORT,
resources.getString(R.string.agg_notification_mute)
).show()
}
// 开启/关闭静音
Log.i(TAG, "initView: mIsMute=$mIsMute")
mAudioManager?.isMicrophoneMute = mIsMute
}
binding.talkingTime.visibility = View.VISIBLE
startRecordTalkingTime()
}
private fun startRecordTalkingTime() {
Log.i(TAG, "startRecordTalkingTime: mCurrentTalkingTime = $mCurrentTalkingTime")
mTalkingTimer.cancel()
mTalkingTimer = Timer()
mTalkingTimer.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
binding.talkingTime.post {
binding.talkingTime.text = DateUtils.getTalkingTimeString(mCurrentTalkingTime++)
}
}
}, 0, PHONE_TALKING_TIME_UPDATE)
}
private fun terminatedCall(delayMillis: Long) {
Log.i(TAG, "terminatedCall: delayMillis = $delayMillis")
mTalkingTimer.cancel()
binding.talkingTime.text = getString(R.string.agg_notification_phone_finish)
binding.talkingTime.postDelayed({ finish() }, delayMillis)
}
}
5、通话时长相关:
/**
* 通话时长更新。单位:ms
*/
const val PHONE_TALKING_TIME_UPDATE = 1000L
/**
* 通话结束UI停留时长。单位:ms
*/
const val PHONE_TALKING_UI_DISMISS = 2000L
/**
* 获取来电,通话时长字符串
*/
fun getTalkingTimeString(seconds: Int): String {
return if (seconds <= 0) {
"00:00:00"
} else if (seconds < 60) {
String.format(Locale.getDefault(), "00:00:%02d", seconds % 60)
} else if (seconds < 3600) {
String.format(Locale.getDefault(), "00:%02d:%02d", seconds / 60, seconds % 60)
} else {
String.format(
Locale.getDefault(),
"%02d:%02d:%02d",
seconds / 3600,
seconds % 3600 / 60,
seconds % 60
)
}
}
private fun startRecordTalkingTime() {
Log.i(TAG, "startRecordTalkingTime: mCurrentTalkingTime = $mCurrentTalkingTime")
mTalkingTimer.cancel()
mTalkingTimer = Timer()
mTalkingTimer.scheduleAtFixedRate(object : TimerTask() {
override fun run() {
binding.talkingTime.post {
binding.talkingTime.text = DateUtils.getTalkingTimeString(mCurrentTalkingTime++)
}
}
}, 0, PHONE_TALKING_TIME_UPDATE)
}
6、音效播放相关:
object SoundPoolTools {
const val RING = 1
const val MUSIC = 2
const val NOTIFICATION = 3
@IntDef(RING, MUSIC, NOTIFICATION)
@Retention(AnnotationRetention.SOURCE)
private annotation class Type
private val TAG = SoundPoolTools::class.java.simpleName
fun play(context: Context, @Type type: Int, resId: Int?) {
// 若是静音不播放
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
if (audioManager.ringerMode == AudioManager.RINGER_MODE_SILENT) {
Log.i(TAG, "play: RINGER_MODE_SILENT")
return
}
// 获取音效默认音量
val sSoundEffectVolumeDb =
context.resources.getInteger(com.android.internal.R.integer.config_soundEffectVolumeDb)
val volFloat: Float = 10.0.pow((sSoundEffectVolumeDb.toFloat() / 20).toDouble()).toFloat()
// 获取音效类型
val streamType = when (type) {
RING -> AudioManager.STREAM_RING
MUSIC -> AudioManager.STREAM_MUSIC
NOTIFICATION -> AudioManager.STREAM_NOTIFICATION
else -> AudioManager.STREAM_MUSIC
}
// 获取音效资源
val rawId = resId ?: when (type) {
RING -> R.raw.notification_message
MUSIC -> R.raw.notification_message
NOTIFICATION -> R.raw.notification_message
else -> R.raw.notification_message
}
SoundPool(1, streamType, 0).apply {
// 1. 加载音效
val soundId = load(context, rawId, 1)
setOnLoadCompleteListener { _, _, _ ->
// 2. 播放音效
// soundId:加载的音频资源的 ID。
// leftVolume和rightVolume:左右声道的音量,范围为 0.0(静音)到 1.0(最大音量)。
// priority:播放优先级,一般设为 1。
// loop:是否循环播放,0 表示不循环,-1 表示无限循环。
// rate:播放速率,1.0 表示正常速率,更大的值表示更快的播放速率,0.5 表示慢速播放。
play(soundId, volFloat, volFloat, 1, 0, 1.0f)
}
}
}
}
7、获取联系人名字:
fun getContactNameFromPhoneBook(context: Context, phoneNum: String): String {
var contactName = ""
try {
context.contentResolver.query(
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
null,
ContactsContract.CommonDataKinds.Phone.NUMBER + " = ?",
arrayOf(phoneNum),
null
)?.let {
if (it.moveToFirst()) {
contactName = it.getString(
it.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
)
it.close()
}
}
} catch (e: Exception) {
e.printStackTrace()
}
return contactName
}
步骤三:调用拨号/接通/拒接等操作
private var headsetClient: BluetoothHeadsetClient? = null
private var call: BluetoothHeadsetClientCall? = null
private var mAudioManager: AudioManager? = null
fun t(){
// 拒接
headsetClient?.rejectCall(call?.device)
// 接听
headsetClient?.acceptCall(call?.device, BluetoothHeadsetClient.CALL_ACCEPT_NONE)
// 挂断
headsetClient?.terminateCall(call?.device, call)
// 拨打
headsetClient?.dial(call?.device, number)
// 打开蓝牙音频通道------通话对方声音从眼镜端输出
headsetClient!!.connectAudio(call?.device)
// 关闭蓝牙音频通话------通话对方声音从手机端输出
headsetClient!!.disconnectAudio(call?.device)
// 打开/关闭通话己方声音
mAudioManager = context.getSystemService(Context.AUDIO_SERVICE)
mAudioManager?.isMicrophoneMute = mIsMute
}
4. ✅ 小结
对于蓝牙通话,本文只是一个基础实现方案,更多业务细节请参考产品逻辑去实现。
另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。