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

目录

[📂 前言](#📂 前言)

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

步骤一:屏蔽原生蓝牙电话相关功能
  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.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. ✅ 小结

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

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


相关推荐
Swuagg2 个月前
AR 眼镜之-蓝牙电话-来电铃声与系统音效
ar眼镜·蓝牙电话·系统音效·来电铃声
Eric.Lee20212 个月前
2024 Snap 新款ar眼镜介绍
人工智能·ar·ar 眼镜·手势交互