AR 眼镜之-蓝牙电话-实现方案

目录

📂 前言

AR 眼镜系统版本

蓝牙电话

来电铃声

1. 🔱 技术方案

1.1 结构框图

1.2 方案介绍

1.3 实现方案

步骤一:屏蔽原生蓝牙电话相关功能

步骤二:自定义蓝牙电话实现

2. 💠 屏蔽原生蓝牙电话相关功能

2.1 蓝牙电话核心时序图

2.2 实现细节

步骤一:禁止系统拉起来去电页面 InCallActivity

步骤二:屏蔽来电消息 Notification 显示

步骤三:替换来电铃声

3. ⚛️ 自定义蓝牙电话实现

3.1 自定义蓝牙电话时序图

3.2 实现细节

步骤一:注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态

步骤二:开发来电弹窗、来电界面,并处理相关业务逻辑

步骤三:调用拨号/接通/拒接等操作

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 实现方案

步骤一:屏蔽原生蓝牙电话相关功能
  1. 禁止系统拉起来去电页面 InCallActivity;

  2. 屏蔽来电消息 Notification 显示;

  3. 替换来电铃声。

步骤二:自定义蓝牙电话实现
  1. 注册 BluetoothHeadsetClient.ACTION_CALL_CHANGED 广播 Action 监听来电/接通/挂断状态;

  2. 开发来电弹窗、来电界面,并处理相关业务逻辑;

  3. 通过 BluetoothAdapter 获取并且初始化 BluetoothHeadsetClient 对象,然后就可以调用 api:dial()/acceptCall()/rejectCall()/terminateCall() 方法进行拨号/接通/拒接的操作。

2. 💠 屏蔽原生蓝牙电话相关功能

  1. 系统来去电页面处理类:w517\packages\apps\Dialer\java\com\android\incallui\InCallPresenter.java

  2. 系统来电消息通知类:w517\packages\apps\Dialer\java\com\android\incallui\StatusBarNotifier.java

  3. 系统来电铃声类:w517\packages\services\Telecomm\src\com\android\server\telecom\Ringer.java

  4. 系统来电铃声文件路径: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.Contextprivate var headsetClient: BluetoothHeadsetClient? = nullfun getHeadsetClient(context: Context): BluetoothHeadsetClient? {if (headsetClient != null) return headsetClientBluetoothAdapter.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 = truefun getHeadsetClientCall() = headsetClientCallprivate fun handleCallState(context: Context, call: BluetoothHeadsetClientCall) {headsetClientCall = callwhen (call.state) {BluetoothHeadsetClientCall.CALL_STATE_ACTIVE -> {Log.i(TAG, "Call is active:mNumber = ${call.number}")// 佩戴检测逻辑if (headsetClient != null) {val isAudioConnected = headsetClient!!.getAudioState(call.device) == 2Log.i(TAG, "isAudioConnected = $isAudioConnected,isWearing = $isWearing")if (isWearing) {if (!isAudioConnected) {headsetClient!!.connectAudio(call.device)}} else {if (isAudioConnected) {headsetClient!!.disconnectAudio(call.device)}}}if (isInComing) {isInComing = falsePhoneTalkingDialogHelper.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 = truePhoneTalkingDialogHelper.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 = falsePhoneTalkingDialogHelper.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.simpleNameprivate var mInCallDialog: View? = nullprivate var mWindowManager: WindowManager? = nullprivate var mLayoutParams: WindowManager.LayoutParams? = nullprivate 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 WindowManagermLayoutParams = WindowManager.LayoutParams().apply {type = WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANELgravity = Gravity.CENTERwidth = (354 * context.resources.displayMetrics.density + 0.5f).toInt()height = WindowManager.LayoutParams.WRAP_CONTENTflags =(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 = falseremoveDialog()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.simpleNameprivate var mTalkingDialog: View? = nullprivate var mContentView: TextView? = nullprivate var mTerminateView: ImageView? = nullprivate var mWindowManager: WindowManager? = nullprivate var mLayoutParams: WindowManager.LayoutParams? = nullprivate var mTalkingTimer = Timer()private var mCurrentTalkingTime = 0fun 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 = 0mTalkingTimer.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 = nullmTalkingTimer.cancel()}}}}fun terminatedCall(context: Context, delayMillis: Long) {Log.i(TAG, "terminatedCall: delayMillis = $delayMillis")mTalkingTimer.cancel()mTerminateView?.isEnabled = falsemContentView?.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 WindowManagermLayoutParams = WindowManager.LayoutParams().apply {type = WindowManager.LayoutParams.TYPE_NAVIGATION_BAR_PANELgravity = Gravity.CENTERwidth = (354 * context.resources.displayMetrics.density + 0.5f).toInt()height = WindowManager.LayoutParams.WRAP_CONTENTflags =(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:

<activityandroid: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? = nullprivate var headsetClient: BluetoothHeadsetClient? = nullprivate lateinit var binding: NotificationActivityPhoneTalkingBindingprivate var mCurrentTalkingTime = 0private var mIsMute = falseprivate var mInitIsMute = falseprivate var mAudioManager: AudioManager? = nullprivate var mTalkingTimer = Timer()companion object {private val TAG = PhoneTalkingActivity::class.java.simpleNameprivate 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.packageNameintent.flags = Intent.FLAG_ACTIVITY_NEW_TASKintent.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 = mInitIsMuteLog.i(TAG, "onDestroy: mInitIsMute = $mInitIsMute,mIsMute = $mIsMute")}private fun initAudio() {mAudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManagermInitIsMute = mAudioManager?.isMicrophoneMute == truemIsMute = mInitIsMuteLog.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.GONEbinding.hangup.visibility = View.GONE// 还未接入指环,先不显示指环动画
//            ringAnimationLayout.visibility = View.GONEbinding.hangupBig.visibility = View.VISIBLEbinding.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.VISIBLEbinding.mute.setOnClickListener {if (mIsMute) {mIsMute = falsebinding.mute.setImageResource(R.drawable.notification_mute_close)} else {mIsMute = truebinding.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.VISIBLEstartRecordTalkingTime()}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 = 1const val MUSIC = 2const val NOTIFICATION = 3@IntDef(RING, MUSIC, NOTIFICATION)@Retention(AnnotationRetention.SOURCE)private annotation class Typeprivate val TAG = SoundPoolTools::class.java.simpleNamefun play(context: Context, @Type type: Int, resId: Int?) {// 若是静音不播放val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManagerif (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_RINGMUSIC -> AudioManager.STREAM_MUSICNOTIFICATION -> AudioManager.STREAM_NOTIFICATIONelse -> AudioManager.STREAM_MUSIC}// 获取音效资源val rawId = resId ?: when (type) {RING -> R.raw.notification_messageMUSIC -> R.raw.notification_messageNOTIFICATION -> R.raw.notification_messageelse -> 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? = nullfun 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. ✅ 小结

        对于蓝牙通话,本文只是一个基础实现方案,更多业务细节请参考产品逻辑去实现。

        另外,由于本人能力有限,如有错误,敬请批评指正,谢谢。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/50315.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[linux] seqeval安装报错

新建一个新的环境 然后安装&#xff1a; # 不能拷贝别人的环境再安mebert_wash的环境。有冲突。我需要重新安一个空的conda环境&#xff0c;再安装。 # conda create -n wash python3.10 ipykernel python -m pip install --upgrade setuptools python -m pip install --upgr…

【Unity】关于Luban的简单使用

最近看了下Luban导出Excel数据的方式&#xff0c;来记录下 【Unity】关于Luban的简单使用 安装Luban开始使用UnityLubanC# 扩展 安装Luban Luban文档&#xff1a;https://luban.doc.code-philosophy.com/docs/beginner/quickstart 1.安装dotnet sdk 8.0或更高版本sdk 2.githu…

Mysql或MariaDB数据库的用户与授权操作——实操保姆级教程

一、问题描述 在日常的工作中,我们需要给不同角色的人员创建不同的账号,他们各自可访问的数据库或权限不一样,这时就需要创建用户和赋予不同的权限内容了。 二、问题分析 1、创建不同的角色账号; 2、给这些账号授予各自可访问数据库的权限。 三、实现方法 Centos8安装…

STM32——GPIO(LED闪烁)

一、什么是GPIO&#xff1f; GPIO&#xff08;通用输入输出接口&#xff09;&#xff1a; 1.GPIO 功能概述 GPIO 是通用输入/输出&#xff08;General Purpose I/O&#xff09;的简称&#xff0c;既能当输入口使用&#xff0c;又能当输出口使用。端口&#xff0c;就是元器件…

android settings提示音开关状态与修改(一)

android系统&#xff0c;settings提示音类型&#xff1a; 提示音开关默认状态&#xff0c;定义文件&#xff1a; frameworks/base/packages/SettingsProvider/res/values/defaults.xml 提示音默认定义&#xff1a; // 锁屏提示音 <integer name"def_lockscreen_sounds_…

2025第十九届中国欧亚国际军民两用技术及西安国防电子航空航天暨无人机展

2025第十九届中国欧亚国际军民两用技术及西安国防电子航空航天暨无人机展 时间&#xff1a;2025年3月14-16日 地点&#xff1a;西安国际会展中心 详询主办方陆先生 I38&#xff08;前三位&#xff09; I82I&#xff08;中间四位&#xff09; 9I72&#xff08;后面四位&am…

Nacos 2.x 新增 grpc 端口,Nginx 需要配置TCP端口转发的注意事项

Nacos 2.x 开始&#xff0c;最大的变化就是端口。在默认主端口 8848 之外又新增了三个端口&#xff0c;新增端口是在配置的主端口 server.port 的基础上&#xff0c;进行一定偏移量自动生成。 8848&#xff08;主端口&#xff0c;默认8848&#xff09;web页面端口及相关http接口…

导航网站WP主题/WP黑格导航主题BlackCandy-简约酷黑色高逼格+焕然一新的UI设计

源码简介&#xff1a; 导航网站WP主题-WP黑格导航主题BlackCandy&#xff0c;它有着简约酷黑色高逼格&#xff0c;而且有焕然一新的UI设计。它是一个简约漂亮的 WordPress 自媒体主题。黑格网址导航主题&#xff0c;自适应电脑端和手机端。 BlackCandy-V2.0这次全新升级了&am…

vite构建vue3项目hmr生效问题踩坑记录

vite构建vue3项目hmr生效问题踩坑记录 hmr的好处 以下是以表格形式呈现的前端开发中HMR&#xff08;热模块替换&#xff09;带来的好处&#xff1a; 好处描述提升开发效率允许开发者在不刷新整个页面的情况下实时更新修改的代码&#xff0c;减少等待时间保持应用状态在模块替…

Oauth2协议的四种模式

B站视频 概念 Oauth2.0&#xff08;Open Authorization&#xff09; 一个关于授权的开放网络标准 允许用户授权第三方应用访问用户存储在其他服务提供者上的信息 不需要将用户名和密码提供给第三方应用 Oauth2中的各个角色 授权码模式 第一步 获取授权码 以上流程中的授…

Unity3D结合AI教育大模型 开发AI教师 AI外教 AI英语教师案例

自2022年底ChatGPT引爆全球之后&#xff0c;大模型技术便迎来了一段崭新的快速发展期&#xff0c;由其在GPT4.0发布后&#xff0c;AI与教育领域结合产品研发、已成为教育AI科技竞争的新高地、未来产业的新赛道、经济发展的新引擎和新产品的诞生地。 据不完全统计&#xff0c;目…

Robot Operating System——Parameter设置的预处理、校验和成功回调

大纲 预处理校验成功回调完整代码测试总结 在《Robot Operating System——对Parameter设置进行校验》一文中&#xff0c;我们通过Node的add_on_set_parameters_callback方法&#xff0c;设置了一个回调函数&#xff0c;用于校验传递过来的Parameter参数。但是这个方法并不能对…

go程序在windows服务中优雅开启和关闭

本篇主要是讲述一个go程序&#xff0c;如何在windows服务中优雅开启和关闭&#xff0c;废话不多说&#xff0c;开搞&#xff01;&#xff01;&#xff01;   使用方式&#xff1a;go程序 net服务启动 Ⅰ 开篇不利 Windows go进程编译后&#xff0c;为一个.exe文件,直接执行即…

[Vulnhub] Raven WordPress+SUDO-Python权限提升+MSQP自动化Mysql权限提升

信息收集 IP AddressOpening Ports192.168.101.159TCP: $ nmap -p- 192.168.101.159 --min-rate 1000 -sC -sV PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 6.7p1 Debian 5deb8u4 (protocol 2.0) | ssh-hostkey: | 1024 26:81:c1:f3:5e:01:ef:93:4…

【Java版数据结构】初识泛型

看到这句话的时候证明&#xff1a;此刻你我都在努力 加油陌生人 br />个人主页&#xff1a;Gu Gu Study专栏&#xff1a;Java版数据结构 喜欢的一句话&#xff1a; 常常会回顾努力的自己&#xff0c;所以要为自己的努力留下足迹 喜欢的话可以点个赞谢谢了。 作者&#xff1…

指针数组模拟二维数组

有任何不懂的问题可以评论区留言&#xff0c;能力范围内都会一一回答 指针数组&#xff0c;是数组&#xff0c;但是它的元素都是指针。 #define _CRT_SECURE_NO_WARNING #include <stdio.h> int main(void) {int a 3;int b 4;int c 5;int* arr[3] { &a,&b,…

STM32的外部中断详解

一、什么是中断&#xff1f; 想象一下你正在家里做饭&#xff0c;突然门铃响了&#xff0c;你听到门铃声后&#xff0c;会暂时放下手中的事情&#xff08;比如炒菜&#xff09;&#xff0c;去开门看看是谁。在这个例子中&#xff0c;门铃声就是一个“中断”&#xff0c;它打断…

【C语言】 二叉树创建(结构体,先序遍历,中序遍历,后续遍历)

二叉树的创建&#xff1a;首先先定义一个结构体&#xff0c;里面包含数据&#xff08;data&#xff09;&#xff0c;指向左子树的指针&#xff08;L&#xff09;&#xff0c;指向右子树的指针&#xff08;R&#xff09;三个部分 在创建树的函数中&#xff0c;首先先输入…

HCIP作业3——MGRE综合实验

一、实验及拓扑 二、实验步骤 1、配置IP R1 [R1]int g0/0/0 [R1-GigabitEthernet0/0/0]ip add 192.168.1.254 24 [R1-GigabitEthernet0/0/0]int s4/0/0 [R1-Serial4/0/0]ip add 15.1.1.1 24 [R1-Serial4/0/0]quitR2 [R2]int g0/0/0 [R2-GigabitEthernet0/0/0]ip add 192.16…

qt国际化

1.pro文件里添加那个…ts. 2.开始-qt-qtxxxfor mingw.然后切换到pro所在的目录。 3.输入lupdate testguojihua.pro,会发现生成一个.ts的文件 4.开始–qt–Linguist,然后打开那个文件。 5.选择文件-发布&#xff0c;就能找到.qm文件了 6.使用这个qm文件