当前位置: 首页 > news >正文

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. ✅ 小结

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

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


相关文章:

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

目录 &#x1f4c2; 前言 AR 眼镜系统版本 蓝牙电话 来电铃声 1. &#x1f531; 技术方案 1.1 结构框图 1.2 方案介绍 1.3 实现方案 步骤一&#xff1a;屏蔽原生蓝牙电话相关功能 步骤二&#xff1a;自定义蓝牙电话实现 2. &#x1f4a0; 屏蔽原生蓝牙电话相关功能 …...

stl-set

目录 目录 内部自动有序、不含重复元素 关于能不能自己造一个cmp&#xff0c;还挺复杂。 访问&#xff1a;只能用迭代器且受限 添加元素&#xff1a;没有pushback&#xff0c;用insert 复杂度&#xff1a;ologn ​编辑 查找元素find&#xff08;&#xff09;&#xff1…...

【Stable Diffusion】(基础篇五)—— 使用SD提升分辨率

使用SD提升分辨率 本系列博客笔记主要参考B站nenly同学的视频教程&#xff0c;传送门&#xff1a;B站第一套系统的AI绘画课&#xff01;零基础学会Stable Diffusion&#xff0c;这绝对是你看过的最容易上手的AI绘画教程 | SD WebUI 保姆级攻略_哔哩哔哩_bilibili 在前期作画的…...

5.CSS学习(浮动)

浮动&#xff08;float&#xff09; 是一种传统的网页布局方式&#xff0c;通过浮动&#xff0c;可以使元素脱离文档流的控制&#xff0c;使其横向排列。 其编写在CSS样式中。 float:none(默认值) 元素不浮动。 float:left 设置的元素在其包含…...

Spring Cloud微服务项目统一封装数据响应体

在微服务架构下&#xff0c;处理服务之间的通信和数据一致性是一个重要的挑战。为了提高开发效率、保证数据的一致性及简化前端开发&#xff0c;统一封装数据响应体是一种非常有效的实践。本文博主将介绍如何在 Spring Cloud 微服务项目中统一封装数据响应体&#xff0c;并分享…...

java算法day20

java算法day20 701.二叉搜索树中的插入操作450.删除二叉搜索树中的节点108 将有序数组转换为二叉搜索树 本次的题目都是用递归函数的返回值来完成&#xff0c;多熟悉这样的用法&#xff0c;很方便。 其实我感觉&#xff0c;涉及构造二叉树的题目&#xff0c;用递归函数的返回值…...

web自动化测试-python+selenium+unitest

文章目录 Web自动化测试工具1. 主流的Web自动化测试工具2. Selenium家族史 Web自动化测试环境搭建基于Python环境搭建示例&#xff1a;通过程序启动浏览器&#xff0c;并打开百度首页&#xff0c;暂停3秒&#xff0c;关闭浏览器 页面元素定位1. 如何进行元素定位&#xff1f;2.…...

LeetCode题练习与总结:组合两个表--175

一、题目描述 SQL Schema > Pandas Schema > 表: Person ---------------------- | 列名 | 类型 | ---------------------- | PersonId | int | | FirstName | varchar | | LastName | varchar | ---------------------- personId 是该表的主…...

数据结构:二叉搜索树(简单C++代码实现)

目录 前言 1. 二叉搜索树的概念 2. 二叉搜索树的实现 2.1 二叉树的结构 2.2 二叉树查找 2.3 二叉树的插入和中序遍历 2.4 二叉树的删除 3. 二叉搜索树的应用 3.1 KV模型实现 3.2 应用 4. 二叉搜索树分析 总结 前言 本文将深入探讨二叉搜索树这一重要的数据结构。二…...

深入理解Prompt工程

前言&#xff1a;因为大模型的流行&#xff0c;衍生出了一个小领域“Prompt工程”&#xff0c;不知道大家会不会跟小编一样&#xff0c;不就是写提示吗&#xff0c;这有什么难的&#xff0c;不过大家还是不要小瞧了Prompt工程&#xff0c;现在很多大模型把会“Prompt工程”作为…...

代码随想录算法训练营day6 | 242.有效的字母异位词、349. 两个数组的交集、202. 快乐数、1.两数之和

文章目录 哈希表键值 哈希函数哈希冲突拉链法线性探测法 常见的三种哈希结构集合映射C实现std::unordered_setstd::map 小结242.有效的字母异位词思路复习 349. 两个数组的交集使用数组实现哈希表的情况思路使用set实现哈希表的情况 202. 快乐数思路 1.两数之和思路 总结 今天是…...

vue3 vxe-table 点击行,不显示选中状态,加上设置isCurrent: true就可以设置选中行的状态。

1、上个图&#xff0c;要实现这样的&#xff1a; Vxe Table v4.6 官方文档 2、使用 row-config.isCurrent 显示高亮行&#xff0c;当前行是唯一的&#xff1b;用户操作点击选项时会触发事件 current-change <template><div><p><vxe-button click"sel…...

Linux没有telnet 如何测试对端的端口状态

前段时间有人问uos没有telnet&#xff0c;又找不到包。 追问了一下为什么非要安装telnet&#xff0c;答复是要测试对端的端口号。 这里简单介绍一下&#xff0c;测试端口号的方法有很多&#xff0c;telent只是在windows上经常使用&#xff0c;linux已很少安装并使用该命令&…...

花几千上万学习Java,真没必要!(二十九)

1、基本数据类型包装类&#xff1a; 测试代码1&#xff1a; package apitest.com; //使用Integer类的不同方法处理整数。 //将字符串转换为整数&#xff08;parseInt&#xff09;和Integer对象&#xff08;valueOf&#xff09;&#xff0c; //将整数转换回字符串&#xff08;…...

C#如何引用dll动态链接库文件的注释

1、dll动态库文件项目生成属性中要勾选“XML文档文件” 注意&#xff1a;XML文件的名字切勿修改。 2、添加引用时XML文件要与DLL文件在同一个目录下。 3、如果要是添加引用的时候XML不在相同目录下&#xff0c;之后又将XML文件复制到相同的目录下&#xff0c;需要删除引用&am…...

WordPress原创插件:自定义文章标题颜色

插件设置截图 文章编辑时&#xff0c;右边会出现一个标题颜色设置&#xff0c;可以设置为任何颜色 更新记录&#xff1a;从输入颜色css代码&#xff0c;改为颜色选择器&#xff0c;更方便&#xff01; 插件免费下载 https://download.csdn.net/download/huayula/89585192…...

Unity分享:继承自MonoBehaviour的脚步不要对引用类型的字段在声明时就初始化

如果某些字段在每个构造函数中都要进行初始化&#xff0c;很多人都喜欢在字段声明时就进行初始化&#xff0c;对于一个非继承自MonoBehaviour的脚步&#xff0c;这样做是没有问题的&#xff0c;然而继承自MonoBehaviour后就会造成内存的浪费&#xff0c;为什么呢&#xff1f;因…...

.NET Core中如何集成RabbitMQ

在.NET Core中集成RabbitMQ主要涉及到几个步骤&#xff0c;包括安装RabbitMQ的NuGet包、建立连接、定义队列、发送和接收消息等。下面是一个简单的指南来展示如何在.NET Core应用程序中集成RabbitMQ。 目录 1. 安装RabbitMQ.Client NuGet包 2. 建立连接 3. 定义队列 4. 发…...

嵌入式C++、STM32、MySQL、GPS、InfluxDB和MQTT协议数据可视化:智能物流管理系统设计思路流程(附代码示例)

目录 项目概述 系统设计 硬件设计 软件设计 系统架构图 代码实现 1. STM32微控制器与传感器代码 代码讲解 2. MQTT Broker设置 3. 数据接收与处理 代码讲解 4. 数据存储与分析 5. 数据分析与可视化 代码讲解 6. 数据可视化 项目总结 项目概述 随着电子商务的快…...

.net core docker部署教程和细节问题

在.NET Core中实现Docker一键部署&#xff0c;通常涉及以下几个步骤&#xff1a;编写Dockerfile以定义镜像构建过程、构建Docker镜像、运行Docker容器&#xff0c;以及&#xff08;可选地&#xff09;使用自动化工具如Docker Compose或CI/CD工具进行一键部署。以下是一个详细的…...

php数据库链接

Php超全局变量 GET 和 POST 都创建一个数组&#xff08;例如 array&#xff08; key1 > value1&#xff0c; key2 > value2&#xff0c; key3 > value3&#xff0c; ...&#xff09;&#xff09;。此数组包含键/值对&#xff0c;其中 键是表单控件的名称&#xff0c;…...

python+vue3+onlyoffice在线文档系统实战20240726笔记,左侧菜单实现和最近文档基本实现

解决右侧高度过高的问题 解决方案&#xff1a;去掉右侧顶部和底部。 实现左侧菜单 最近文档&#xff0c;纯粹文档 我的文档&#xff0c;既包括文件夹也包括文件 共享文档&#xff0c;别人分享给我的 基本实现代码&#xff1a; 渲染效果&#xff1a; 简单优化 设置默认菜…...

vue中的nexttrick

Vue.js 是一个用于构建用户界面的渐进式框架&#xff0c;它允许开发者通过声明式的数据绑定来构建网页应用。在 Vue 中&#xff0c;nextTick 是一个非常重要的 API&#xff0c;它用于延迟回调的执行&#xff0c;直到下次 DOM 更新循环之后。 为什么使用 nextTick&#xff1f; …...

【BUG】已解决:ModuleNotFoundError: No module named ‘requests‘

ModuleNotFoundError: No module named ‘requests‘ 目录 ModuleNotFoundError: No module named ‘requests‘ 【常见模块错误】 【解决方案】 欢迎来到英杰社区https://bbs.csdn.net/topics/617804998 欢迎来到我的主页&#xff0c;我是博主英杰&#xff0c;211科班出身&a…...

深入理解JS中的发布订阅模式和观察者模式

发布/订阅模式(Publish/Subscribe)和观察者模式(Observer Pattern)在概念上非常相似,都是用于实现对象之间的松耦合通信。尽管它们在实现细节和使用场景上有所不同,但核心思想是相通的。 观察者模式 直接通信:在观察者模式中,观察者(Observer)直接订阅主题(Subject…...

网站IPv6支持率怎么检测?

在当今数字化的时代&#xff0c;IPv6的推广和应用已经成为网络发展的重要趋势。IPv6拥有更大的地址空间、更高的安全性和更好的性能&#xff0c;对于满足日益增长的网络需求至关重要。对于网站所有者和管理员来说&#xff0c;了解其网站对IPv6的支持率是评估网站性能和兼容性的…...

react中简单的配置路由

1.安装react-router-dom npm install react-router-dom 2.新建文件 src下新建page文件夹&#xff0c;该文件夹下新建login和index文件夹用于存放登录页面和首页&#xff0c;再在对应文件夹下分别新建入口文件index.js&#xff1b; src下新建router文件用于存放路由配置文件…...

RocketMQ消息短暂而又精彩的一生(荣耀典藏版)

目录 前言 一、核心概念 二、消息诞生与发送 2.1.路由表 2.2.队列的选择 2.3.其它特殊情况处理 2.3.1.发送异常处理 2.3.2.消息过大的处理 三、消息存储 3.1.如何保证高性能读写 3.1.1.传统IO读写方式 3.2零拷贝 3.2.1.mmap() 3.2.2sendfile() 3.2.3.CommitLog …...

Linux中的文件操作

linux中exec*为加载器&#xff0c;可以将程序加载到内存。 main()函数也是函数&#xff0c;也要被调用&#xff0c;也要被传参 故在一个程序中exec*系列的函数先被执行 程序替换中execve是系统调用&#xff0c;其他的都是封装。 进程程序替换 1.创建子进程的目的&#xff1…...

[排序]hoare快速排序

今天我们继续来讲排序部分&#xff0c;顾名思义&#xff0c;快速排序是一种特别高效的排序方法&#xff0c;在C语言中qsort函数&#xff0c;底层便是用快排所实现的&#xff0c;快排适用于各个项目中&#xff0c;特别的实用&#xff0c;下面我们就由浅入深的全面刨析快速排序。…...

freertos的学习cubemx版

HAL 库的freertos 1 实时 2 任务->线程 3 移植 CMSIS_V2 V1版本 NVIC配置全部是抢占优先级 第四组 抢占级别有 0-15 编码规则&#xff0c; 变量名 &#xff1a;类型前缀&#xff0c; c - char S - int16_t L - int32_t U - unsigned Uc - uint8_t Us - uint…...

PyQt 信号与槽功能

PyQt 信号与槽功能 基本概念&#xff1a;在 PyQt 中&#xff0c;信号&#xff08;Signal&#xff09;与槽&#xff08;Slot&#xff09;是一种用于对象之间通信的机制。信号可以由一个对象发出&#xff0c;而槽是用于接收信号并执行相应操作的函数。 信号 信号是在 PyQt 的类…...

navicat premium安装和破解

https://blog.csdn.net/qq1031893936/article/details/90264688 提示信息 - 吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn...

OSI七层模型

OSI&#xff08;Open System Interconnect&#xff09;&#xff0c;即开放式系统互连。 该体系结构标准定义了网络互连的七层框架&#xff08;物理层、数据链路层、网络层、传输层、会话层、表示层和应用层 &#xff09;&#xff0c;即OSI开放系统互连参考模型。 应用层 为用…...

Qt自定义MessageToast

效果&#xff1a; 文字长度自适应&#xff0c;自动居中到parent&#xff0c;会透明渐变消失。 CustomToast::MessageToast(QS("最多添加50张图片"),this);1. CustomToast.h #pragma once#include <QFrame>class CustomToast : public QFrame {Q_OBJECT pub…...

自动化测试 pytest 中 scope 限制 fixture使用范围!

导读 fixture 是 pytest 中一个非常重要的模块&#xff0c;可以让代码更加简洁。 fixture 的 autouse 为 True 可以自动化加载 fixture。 如果不想每条用例执行前都运行初始化方法(可能多个fixture)怎么办&#xff1f;可不可以只运行一次初始化方法&#xff1f; 答&#xf…...

软件-vscode-plantUML-drawio

文章目录 vscode基础命令 实操1. vscode实现springboot项目搭建 &#xff08;包括spring data jpa和sqlLite连接&#xff09; PlantUMLDrawio基础实操 vscode 基础 命令 启动mysql命令 docker run --name mysql-container -e MYSQL_ROOT_PASSWORD123456 -p 3306:3306 -d my…...

Python爬虫实战案例(爬取图片)

爬取图片的信息 爬取图片与爬取文本内容相似&#xff0c;只是需要加上图片的url&#xff0c;并且在查找图片位置的时候需要带上图片的属性。 这里选取了一个4K高清的壁纸网站&#xff08;彼岸壁纸https://pic.netbian.com&#xff09;进行爬取。 具体步骤如下&#xff1a; …...

智慧工地视频汇聚管理平台:打造现代化工程管理的全新视界

一、方案背景 科技高速发展的今天&#xff0c;工地施工已发生翻天覆地的变化&#xff0c;传统工地管理模式很容易造成工地管理混乱、安全事故、数据延迟等问题&#xff0c;人力资源的不足也进一步加剧了监管不到位的局面&#xff0c;严重影响了施工进度质量和安全。 视频监控…...

ASP.NET中的六大对象有哪些?以及各自的功能以及使用方式

在ASP.NET Web Forms中&#xff0c;并没有严格意义上的“六大对象”&#xff0c;但通常我们指的是与HTTP请求和响应处理紧密相关的几个内置对象。以下是这些对象及其功能、使用方式以及简单的实现源码示例&#xff1a; Response对象 功能&#xff1a;用于向客户端发送HTTP响应…...

Elastic 及阿里云 AI 搜索 Tech Day 将于 7 月 27 日在上海举办

活动主题 面向开发者的 AI 搜索相关技术分享&#xff0c;如 RAG、多模态搜索、向量检索等。 活动介绍 参加 Elastic 原厂与阿里云联合举办的 Generative AI 技术交流分享日。借助 The Elastic Search AI Platform&#xff0c; 使用开放且灵活的企业解决方案&#xff0c;以前所…...

基于ssm+vue医院住院管理系统源码数据库

摘 要 随着时代的发展&#xff0c;医疗设备愈来愈完善&#xff0c;医院也变成人们生活中必不可少的场所。如今&#xff0c;已经2021年了&#xff0c;虽然医院的数量和设备愈加完善&#xff0c;但是老龄人口也越来越多。在如此大的人口压力下&#xff0c;医院住院就变成了一个…...

【在排序数组中查找元素的第一个和最后一个位置】python刷题记录

R2-分治 有点easy的感觉&#xff0c;感觉能用哈希表 class Solution:def searchRange(self, nums: List[int], target: int) -> List[int]:nlen(nums)dictdefaultdict(list)#初始赋值哈希表&#xff0c;记录出现次数for num in nums:if not dict[num]:dict[num]1else:dict[…...

Pytorch基础:Tensor的squeeze和unsqueeze方法

相关阅读 Pytorch基础https://blog.csdn.net/weixin_45791458/category_12457644.html?spm1001.2014.3001.5482 在Pytorch中&#xff0c;squeeze和unsqueeze是Tensor的一个重要方法&#xff0c;同时它们也是torch模块中的一个函数&#xff0c;它们的语法如下所示。 Tensor.…...

PHP压缩打包,下载目录或者文件,解压zip文件

函数 /*** 压缩整个文件夹为zip文件* 本地需要绝对路径&#xff0c;服务器需要相对路径*/function makeZipFile($zip_path , $folder_path ) {$rootPath realpath($folder_path);$zip new ZipArchive(); // $zip->open($zip_path, ZipArchive::CREATE | ZipArchi…...

后端面试题日常练-day08 【Java基础】

题目 希望这些选择题能够帮助您进行后端面试的准备&#xff0c;答案在文末 Java中的静态变量和实例变量有何区别&#xff1f; a) 静态变量属于类&#xff0c;实例变量属于对象 b) 静态变量只能在静态方法中访问&#xff0c;实例变量只能在实例方法中访问 c) 静态变量在类加载时…...

Linux:core文件无法生成排查步骤

1、进程的RLIMIT_CORE或RLIMIT_SIZE被设置为0。使用getrlimit和ulimit检查修改。 使用ulimit -a 命令检查是否开启core文件生成限制 如果发现-c后面的结果是0&#xff0c;就临时添加环境变量ulimit -c unlimited&#xff0c;之后在启动程序观察是否有core生成&#xff0c;如果…...

大模型学习资源

上一篇扯了一堆废话&#xff0c;关于大模型&#xff0c;提供一下建议 说实话&#xff0c;大模型更新太快&#xff0c;以我30岁的高龄实在不适合再去研究技术。偶然发现&#xff0c;国内的大模型厂家在做推广的培训。比如上海人工智能实验室&#xff0c;阿里&#xff0c;百度。…...

约定(模拟赛2 T3)

题目描述 小A在你的帮助下成功打开了山洞中的机关&#xff0c;虽然他并没有找到五维空间&#xff0c;但他在山洞中发现了无尽的宝藏&#xff0c;这个消息很快就传了出去。人们为了争夺洞中的宝藏相互陷害&#xff0c;甚至引发了战争&#xff0c;世界都快要毁灭了。小A非常地难…...

Java推送xml数据进行http请求

将json转成xml数据进行推送&#xff0c;打印出最终推送xml的数据格式&#xff0c;再调整代码 直接上代码&#xff0c;详情请看代码注释 public void pushReceipt(JSONObject jsonObj) {try {// 创建 XML 文档Document doc createXmlDocument();// 构建 XML 结构Element rootE…...