feat: 设备绑定与配对模块

新增:
- SplashFragment 启动分发(初始化+绑定检查+导航到Home或Bind)
- BindFragment 二维码配对页面(ZXing生成QR码+MQTT绑定监听)
- WatchBindInfo 绑定信息数据类

修改:
- nav_main.xml startDestination改为SplashFragment,添加导航action
- HomeFragment 移除初始化逻辑到Splash,添加MQTT解绑处理
- CommonApi getWatchByImei返回类型改为WatchBindInfo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongliang
2026-04-27 16:23:37 +09:30
parent a3c1f1d632
commit 1b61abb380
8 changed files with 389 additions and 32 deletions

View File

@@ -0,0 +1,14 @@
package com.xiaoqu.watch.data.device
import com.google.gson.annotations.SerializedName
/**
* 设备绑定信息数据类
* 对应 getWatchByImei API 返回和 MQTT messageType=2 消息中的用户信息
*/
data class WatchBindInfo(
@SerializedName("userId") val userId: Long = 0,
@SerializedName("mobile") val mobile: String = "",
@SerializedName("userName") val userName: String = "",
@SerializedName("headUrl") val headUrl: String = ""
)

View File

@@ -1,5 +1,6 @@
package com.xiaoqu.watch.network.api
import com.xiaoqu.watch.data.device.WatchBindInfo
import com.xiaoqu.watch.network.ApiResponse
import retrofit2.http.Body
import retrofit2.http.GET
@@ -16,9 +17,9 @@ interface CommonApi {
@POST("watch/bindWatchConfirm")
suspend fun bindWatchConfirm(@Body params: Map<String, Any>): ApiResponse<Any>
/** 根据 IMEI 查询手表信息 */
/** 根据 IMEI 查询手表绑定信息(返回用户数据则已绑定,否则未绑定) */
@GET("watch/getWatchByImei")
suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse<Any>
suspend fun getWatchByImei(@Query("imei") imei: String): ApiResponse<WatchBindInfo>
/** 检查版本更新 */
@GET("newAppVersion/queryWatch")

View File

@@ -1,15 +1,158 @@
package com.xiaoqu.watch.ui.bind
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.google.gson.Gson
import com.google.zxing.BarcodeFormat
import com.google.zxing.MultiFormatWriter
import com.google.zxing.common.BitMatrix
import android.graphics.Bitmap
import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.device.WatchBindInfo
import com.xiaoqu.watch.data.prefs.DevicePrefs
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.databinding.FragmentBindBinding
import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.network.api.CommonApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.ui.common.BaseFragment
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* 设备绑定页面
* 显示二维码供手机 APP 扫码绑定
* 监听 MQTT messageType=2 绑定成功消息
*/
@AndroidEntryPoint
class BindFragment : BaseFragment<FragmentBindBinding>() {
@Inject lateinit var devicePrefs: DevicePrefs
@Inject lateinit var userPrefs: UserPrefs
@Inject lateinit var eventBus: EventBus
@Inject lateinit var commonApi: CommonApi
@Inject lateinit var gson: Gson
override fun createBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentBindBinding {
return FragmentBindBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 生成并显示二维码
generateQrCode()
// 监听 MQTT 绑定消息
observeBindEvent()
}
/**
* 生成二维码
* 编码设备信息 JSON{bluetoothName, imei, serial, power}
*/
private fun generateQrCode() {
try {
// 构建设备信息 JSON与旧版 codeScanPair.vue 一致)
val qrData = gson.toJson(mapOf(
"bluetoothName" to devicePrefs.bluetoothName,
"imei" to devicePrefs.imei,
"serial" to devicePrefs.serial,
"power" to 100 // TODO: 接入实时电量
))
Timber.d("绑定: QR 数据=$qrData")
// 使用 ZXing 生成二维码 Bitmap
val size = 500 // 生成 500×500 像素ImageView 会缩放
val bitMatrix: BitMatrix = MultiFormatWriter().encode(
qrData, BarcodeFormat.QR_CODE, size, size
)
val bitmap = bitMatrixToBitmap(bitMatrix)
binding.ivQrCode.setImageBitmap(bitmap)
} catch (e: Exception) {
Timber.e(e, "绑定: 二维码生成失败")
}
}
/**
* 将 ZXing BitMatrix 转换为 Android Bitmap
* 黑色像素用黑色,白色像素用白色
*/
private fun bitMatrixToBitmap(matrix: BitMatrix): Bitmap {
val width = matrix.width
val height = matrix.height
val pixels = IntArray(width * height)
for (y in 0 until height) {
for (x in 0 until width) {
pixels[y * width + x] = if (matrix[x, y]) {
0xFF000000.toInt() // 黑色
} else {
0xFFFFFFFF.toInt() // 白色
}
}
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
/**
* 监听 MQTT 绑定成功消息messageType=2
* 收到后存储用户信息 → 调用 API 确认 → 导航到首页
*/
private fun observeBindEvent() {
viewLifecycleOwner.lifecycleScope.launch {
eventBus.events.collect { event ->
if (event is AppEvent.MqttMessageReceived && event.type == 2) {
handleBindSuccess(event.rawJson)
}
}
}
}
/**
* 处理绑定成功
* 1. 解析用户信息并存入 UserPrefs
* 2. 调用 bindWatchConfirm API 确认
* 3. 导航到首页
*/
private fun handleBindSuccess(rawJson: String) {
try {
Timber.d("绑定: 收到绑定消息 $rawJson")
// 解析用户信息
val bindInfo = gson.fromJson(rawJson, WatchBindInfo::class.java)
// 存入 UserPrefs
userPrefs.saveUser(
userId = bindInfo.userId,
mobile = bindInfo.mobile,
userName = bindInfo.userName,
headUrl = bindInfo.headUrl
)
// 调用 API 确认绑定
viewLifecycleOwner.lifecycleScope.launch {
val params = mapOf<String, Any>(
"imei" to devicePrefs.imei,
"userId" to bindInfo.userId
)
safeApiCall { commonApi.bindWatchConfirm(params) }
Timber.d("绑定: 确认 API 已调用")
}
// 导航到首页
Timber.d("绑定: 绑定成功,导航到首页")
findNavController().navigate(R.id.action_bind_to_home)
} catch (e: Exception) {
Timber.e(e, "绑定: 处理绑定消息异常")
}
}
}

View File

@@ -0,0 +1,136 @@
package com.xiaoqu.watch.ui.common
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import com.xiaoqu.watch.R
import com.xiaoqu.watch.data.prefs.DevicePrefs
import com.xiaoqu.watch.data.prefs.UserPrefs
import com.xiaoqu.watch.network.ApiResult
import com.xiaoqu.watch.network.api.CommonApi
import com.xiaoqu.watch.network.safeApiCall
import com.xiaoqu.watch.service.manager.MqttManager
import com.xiaoqu.watch.util.DeviceUtil
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* 启动分发页面
* 职责:初始化设备信息 → 连接 MQTT → 检查绑定状态 → 导航到首页或绑定页
* 纯逻辑,无 UI黑色背景用户看不到此页面
*/
@AndroidEntryPoint
class SplashFragment : Fragment() {
@Inject lateinit var devicePrefs: DevicePrefs
@Inject lateinit var userPrefs: UserPrefs
@Inject lateinit var mqttManager: MqttManager
@Inject lateinit var commonApi: CommonApi
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return inflater.inflate(R.layout.fragment_splash, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 1. 初始化设备信息(首次启动时写入 SP
initDevicePrefs()
// 2. 连接 MQTT需要 IMEI所以必须在 initDevicePrefs 之后)
mqttManager.connect()
// 3. 检查绑定状态并导航
checkBindStatus()
}
/** 首次启动时初始化设备信息到 device_prefs */
private fun initDevicePrefs() {
if (!devicePrefs.isInitialized) {
val info = DeviceUtil.getDeviceInfo(requireContext())
devicePrefs.saveDeviceInfo(
imei = info.imei, serial = info.serial,
bluetoothName = info.bluetoothName, bluetoothMac = info.bluetoothMac,
brand = info.brand, model = info.model,
osVersion = info.osVersion, totalMemory = info.totalMemory
)
Timber.d("Splash: 设备信息已初始化")
}
}
/**
* 检查绑定状态:
* 1. 调用 API 获取最新绑定状态
* 2. 有数据 → 已绑定 → 更新 UserPrefs → 导航到首页
* 3. 无数据 → 未绑定 → 导航到绑定页
* 4. 网络异常 → 用本地 isBound 兜底
*/
private fun checkBindStatus() {
viewLifecycleOwner.lifecycleScope.launch {
val imei = devicePrefs.imei
// 调用 API 检查绑定状态
val result = safeApiCall { commonApi.getWatchByImei(imei) }
when (result) {
is ApiResult.Success -> {
// API 返回数据 → 已绑定
Timber.d("Splash: API 返回已绑定")
// TODO: 解析返回数据更新 UserPrefs等确认实际字段后完善
navigateToHome()
}
is ApiResult.Error -> {
if (result.code == 1) {
// code=1 未绑定
Timber.d("Splash: API 返回未绑定")
navigateToBind()
} else {
// 其他错误,用本地状态兜底
Timber.w("Splash: API 错误 code=${result.code}, 用本地状态兜底")
navigateByLocalState()
}
}
is ApiResult.NetworkError -> {
// 网络异常,用本地状态兜底
Timber.w("Splash: 网络异常, 用本地状态兜底")
navigateByLocalState()
}
}
}
}
/** 根据本地 UserPrefs 绑定状态导航(离线兜底) */
private fun navigateByLocalState() {
if (userPrefs.isBound) {
navigateToHome()
} else {
navigateToBind()
}
}
/** 导航到首页popUpTo splash 防止返回) */
private fun navigateToHome() {
Timber.d("Splash: → HomeFragment")
findNavController().navigate(
R.id.action_splash_to_home
)
}
/** 导航到绑定页popUpTo splash 防止返回) */
private fun navigateToBind() {
Timber.d("Splash: → BindFragment")
findNavController().navigate(
R.id.action_splash_to_bind
)
}
}

View File

@@ -16,12 +16,10 @@ import com.xiaoqu.watch.device.sensor.VibrationController
import com.xiaoqu.watch.device.sensor.VibrationDefaults
import com.xiaoqu.watch.event.AppEvent
import com.xiaoqu.watch.event.EventBus
import com.xiaoqu.watch.service.manager.MqttManager
import com.xiaoqu.watch.ui.common.BaseFragment
import com.xiaoqu.watch.ui.widget.NavBarHelper
import com.xiaoqu.watch.ui.widget.QuTipDialog
import com.xiaoqu.watch.util.DateUtil
import com.xiaoqu.watch.util.DeviceUtil
import com.xiaoqu.watch.util.NetworkUtil
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.delay
@@ -43,7 +41,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
@Inject lateinit var nfcController: NfcController
@Inject lateinit var vibrationController: VibrationController
@Inject lateinit var eventBus: EventBus
@Inject lateinit var mqttManager: MqttManager
/** 提示弹窗 */
private lateinit var tipDialog: QuTipDialog
@@ -62,15 +59,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// 初始化设备信息
initDevicePrefs()
// 设置 NavBar 为首页模式
// 设置 NavBar 为首页模式initDevicePrefs 和 MQTT 已在 SplashFragment 完成)
NavBarHelper.setupHomePage(binding.root)
// 连接 MQTT必须在 initDevicePrefs 之后,确保 IMEI 可用)
mqttManager.connect()
// 初始化弹窗
val dialogContainer = requireActivity().findViewById<FrameLayout>(R.id.dialog_container)
tipDialog = QuTipDialog(dialogContainer)
@@ -97,19 +88,6 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
}
}
/** 首次启动时初始化设备信息到 device_prefs */
private fun initDevicePrefs() {
if (!devicePrefs.isInitialized) {
val info = DeviceUtil.getDeviceInfo(requireContext())
devicePrefs.saveDeviceInfo(
imei = info.imei, serial = info.serial,
bluetoothName = info.bluetoothName, bluetoothMac = info.bluetoothMac,
brand = info.brand, model = info.model,
osVersion = info.osVersion, totalMemory = info.totalMemory
)
}
}
/** 更新状态信息显示try-catch 保护,防止系统关机时 DeadSystemException */
private fun updateStatus() {
try {
@@ -224,6 +202,12 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>() {
is AppEvent.MqttDisconnected -> updateStatus()
is AppEvent.MqttMessageReceived -> {
Timber.d("MQTT消息: type=${event.type}")
// messageType=3: 解绑 → 清除用户数据,导航到绑定页
if (event.type == 3) {
userPrefs.clear()
findMainNavController().navigate(R.id.action_home_to_bind)
return@collect
}
updateStatus()
}
else -> {} // 其他事件不处理

View File

@@ -1,9 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<!-- BindFragment二维码配对页面全屏无 NavBar -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background">
android:background="@color/background"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/safe_area_left">
<!-- TODO: 二维码 + 设备信息 -->
<!-- 二维码图片 -->
<ImageView
android:id="@+id/ivQrCode"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_marginBottom="@dimen/spacing_lg"
android:contentDescription="配对二维码" />
</FrameLayout>
<!-- 标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="扫码绑定手表"
android:textColor="@color/text_primary"
android:textSize="@dimen/text_title"
android:textStyle="bold"
android:layout_marginBottom="@dimen/spacing_md" />
<!-- 说明文字 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1. 下载小趣智品APP"
android:textColor="@color/text_secondary"
android:textSize="@dimen/text_caption"
android:layout_marginBottom="@dimen/spacing_xs" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="2. 在APP中扫此码添加手表"
android:textColor="@color/text_secondary"
android:textSize="@dimen/text_caption" />
</LinearLayout>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SplashFragment启动分发页纯逻辑无 UI黑色背景 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/background" />

View File

@@ -2,19 +2,56 @@
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_main"
app:startDestination="@id/homeFragment">
app:startDestination="@id/splashFragment">
<!-- 启动分发页(检查绑定状态 → Home 或 Bind -->
<fragment
android:id="@+id/splashFragment"
android:name="com.xiaoqu.watch.ui.common.SplashFragment"
android:label="启动">
<!-- splash → home已绑定 -->
<action
android:id="@+id/action_splash_to_home"
app:destination="@id/homeFragment"
app:popUpTo="@id/splashFragment"
app:popUpToInclusive="true" />
<!-- splash → bind未绑定 -->
<action
android:id="@+id/action_splash_to_bind"
app:destination="@id/bindFragment"
app:popUpTo="@id/splashFragment"
app:popUpToInclusive="true" />
</fragment>
<!-- 首页(含 ViewPager2 左右滑动:设置页 / 主页) -->
<fragment
android:id="@+id/homeFragment"
android:name="com.xiaoqu.watch.ui.home.HomeFragment"
android:label="首页" />
android:label="首页">
<!-- home → bind解绑后 -->
<action
android:id="@+id/action_home_to_bind"
app:destination="@id/bindFragment"
app:popUpTo="@id/homeFragment"
app:popUpToInclusive="true" />
</fragment>
<!-- 设备绑定页(全屏二维码) -->
<fragment
android:id="@+id/bindFragment"
android:name="com.xiaoqu.watch.ui.bind.BindFragment"
android:label="设备绑定" />
android:label="设备绑定">
<!-- bind → home绑定成功后 -->
<action
android:id="@+id/action_bind_to_home"
app:destination="@id/homeFragment"
app:popUpTo="@id/bindFragment"
app:popUpToInclusive="true" />
</fragment>
<!-- 任务列表 -->
<fragment