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

news2024/9/23 5:30:21

目录

📂 前言

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

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

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


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

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

相关文章

深入解读 Java 中的 `StringUtils.isNotBlank` 与 `StringUtils.isNotEmpty`

个人名片 🎓作者简介:java领域优质创作者 🌐个人主页:码农阿豪 📞工作室:新空间代码工作室(提供各种软件服务) 💌个人邮箱:[2435024119@qq.com] 📱个人微信:15279484656 🌐个人导航网站:www.forff.top 💡座右铭:总有人要赢。为什么不能是我呢? 专栏导…

【支持语言模型和视觉语言模型的推理引擎sglang】

介绍 sglang是一个AI推理引擎&#xff0c;是一个专门为大语言模型和视觉语言模型设计的高效服务框架。 就像F1赛车需要顶级发动机一样&#xff0c;大语言模型也需要高效的推理引擎来发挥潜力。 而sglang正是这样一个性能怪兽。 根据LMSys组织的官方公告&#xff0c;最新的s…

Docker(十)-Docker运行elasticsearch7.4.2容器实例以及分词器相关的配置

1.下载镜像 1.1存储和检索数据 docker pull elasticsearch:7.4.2 1.2可视化检索数据 docker pull kibana:7.4.22.创建elasticsearch实例 创建本地挂载数据卷配置目录 mkdir -p /software/elasticsearch/config 创建本地挂载数据卷数据目录 mkdir -p /software/elasticse…

【React】JSX:从基础语法到高级用法的深入解析

文章目录 一、什么是 JSX&#xff1f;1. 基础语法2. 嵌入表达式3. 使用属性4. JSX 是表达式 二、JSX 的注意事项1. 必须包含在单个父元素内2. JSX 中的注释3. 避免注入攻击 三、JSX 的高级用法1. 条件渲染2. 列表渲染3. 内联样式4. 函数作为子组件 四、最佳实践 在 React 开发中…

20240724----idea的Java环境卸载与安装

1.删除旧有的jdk https://blog.csdn.net/weixin_42168713/article/details/112162099 &#xff08;补充&#xff1a;我把用户变量和java有关的都删了&#xff09; 2.下载新的jdk百度网盘链接 链接&#xff1a;https://pan.baidu.com/s/1gkuLoxBuRAtIB1IzUTmfyQ 提取码&#xf…

第二代欧洲结构设计标准简介

文章目录 0、背景1、总览2、更新及变化2.1 抗震2.2 地基基础2.3 防火 0、背景 本篇文章来自微信公众号土木吧&#xff0c;原作者李立昌&#xff08;北京鑫美格工程设计有限公司&#xff09;。对原文感兴趣的可以点击这里。 新的欧标滚滚而来&#xff0c;提前做好准备很有必要…

人工智能视频大模型:重塑视频处理与理解的未来

目录 一、人工智能视频大模型概述 1.1 定义与特点 1.2 技术基础 二、关键技术解析 2.1 视频特征提取 2.2 时空建模 2.3 多任务学习 三、应用场景展望 3.1 视频内容分析 3.2 视频编辑与生成 3.3 交互式视频体验 四、未来发展趋势 4.1 模型轻量化与移动端部署 4.2 …

前端面试项目细节重难点分享(十三)

面试题提问&#xff1a;分享你最近做的这个项目&#xff0c;并讲讲该项目的重难点&#xff1f; 答&#xff1a;最近这个项目是一个二次迭代开发项目&#xff0c;迭代周期一年&#xff0c;在做这些任务需求时&#xff0c;确实有很多值得分享的印象深刻的点&#xff0c;我讲讲下面…

【C语言】队列的实现(数据结构)

前言&#xff1a; 相信大家在生活中经常排队买东西&#xff0c;今天学习的队列就跟排队买东西一样&#xff0c;先来买的人就买完先走&#xff0c;也就是先进先出。废话不多说&#xff0c;进入咱们今天的学习吧。 目录 前言&#xff1a; 队列的概念 队列的实现 队列的定义 …

【8月EI会议推荐】第四届区块链技术与信息安全国际会议

一、会议信息 大会官网&#xff1a;http://www.bctis.nhttp://www.icbdsme.org/ 官方邮箱&#xff1a;icbctis126.com 组委会联系人&#xff1a;杨老师 19911536763 支持单位&#xff1a;中原工学院、西安工程大学、齐鲁工业大学&#xff08;山东省科学院&#xff09;、澳门…

git 学习总结

文章目录 一、 git 基础操作1、工作区2、暂存区3、本地仓库4、远程仓库 二、git 的本质三、分支git 命令总结 作者: baron 一、 git 基础操作 如图所示 git 总共有几个区域 工作区, 暂存区, 本地仓库, 远程仓库. 1、工作区 存放项目代码的地方&#xff0c;他有两种状态 Unm…

RK3588+MIPI+GMSL+AI摄像机:自动车载4/8通道GMSL采集/边缘计算盒解决方案

RK3588作为目前市面能买到的最强国产SOC&#xff0c;有强大的硬件配置。在智能汽车飞速发展&#xff0c;对图像数据矿场要求越来越多的环境下&#xff0c;如何高效采集数据&#xff0c;或者运行AI应用&#xff0c;成为刚需。 推出的4/8通道GMSL采集/边缘计算盒产品满足这些需求…

MinIO存储桶通知 - Kafka小测

概述 公司的某个项目需要用上这玩意&#xff0c;所以在本地搭建测试环境&#xff0c;经过一番折腾&#xff0c;测试通过&#xff0c;博文记录&#xff0c;用以备忘 MinIO安装 该节不做说明&#xff0c;网络有很多现成的帖子&#xff0c;自行搜索去 配置步骤 控制台添加事件…

瑞芯微芯片资料中关于图像处理相关的知识点

目录 MPI层模块介绍IPC的应用像素格式排布系统绑定API接口 MPI层 文件&#xff1a;Rockchip_Developer_Guide_MPI.pdf RK MPI&#xff1a;Rockchip Media Process Interface&#xff0c;媒体处理接口。 模块介绍 RK MPI层的模块介绍&#xff1a; IPC的应用 VI 模块捕获视频…

工业三防平板电脑助力工厂产线管理的智能化转型

在当今高度数字化和智能化的工业时代&#xff0c;工厂产线管理正经历着前所未有的变革。其中&#xff0c;工业三防平板电脑作为一种创新的技术工具&#xff0c;正发挥着日益重要的作用&#xff0c;有力地推动着工厂产线管理向智能化转型。 一、工业三防平板电脑具有出色的防水、…

微信小程序-本地部署(前端)

遇到问题&#xff1a;因为是游客模式所以不能修改appID. 参考链接&#xff1a;微信开发者工具如何从游客模式切换为开发者模式&#xff1f;_微信开发者工具如何修改游客模式-CSDN博客 其余参考&#xff1a;Ego微商项目部署&#xff08;小程序项目&#xff09;&#xff08;全网…

大语言模型是什么,该如何去学习呢

什么是 LLM**&#xff1f;** LLM(大型语言模型&#xff0c; Large Lanage Modle)是一种计算机程序&#xff0c;它可以理解和生成类似人类的文本&#xff1b;它能够像我们人类一样阅读、写作和理解语言。你可以把它想象成一个超级聪明的博学的不知疲惫的24小时全年无休的助手。…

使用代理IP进行本地SEO优化:如何吸引附近的客户?

在今天竞争激烈的互联网时代&#xff0c;如何利用代理IP进行本地SEO优化并吸引附近的客户已经成为许多企业和网站面临的关键挑战。本文将探讨使用代理IP的策略和技巧&#xff0c;以帮助公司提高在本地市场的可见性和吸引力&#xff0c;从而扩大本地客户群体。 1. 代理IP在本地…

小型内衣裤洗衣机哪个牌子好?五款万分翘楚机型任你挑选!

在日常生活中&#xff0c;内衣洗衣机已成为现代家庭必备的重要家电之一。选择一款耐用、质量优秀的内衣洗衣机&#xff0c;不仅可以减少洗衣负担&#xff0c;还能提供高效的洗涤效果。然而&#xff0c;市场上众多内衣洗衣机品牌琳琅满目&#xff0c;让我们往往难以选择。那么&a…

vdb:虚拟数据库

将文件虚拟成数据库&#xff0c;序列化写入、反序列化读取、直接读取。