*
This commit is contained in:
33
core/ble/build.gradle.kts
Normal file
33
core/ble/build.gradle.kts
Normal file
@@ -0,0 +1,33 @@
|
||||
// core:ble 蓝牙层:设备扫描(名称前缀过滤)+ GATT 连接封装骨架
|
||||
// 权限申请由 app 壳负责(Android 12L 分版见 app 模块),本模块假定权限已授予
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.ble"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
191
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
Normal file
191
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleConnector.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCallback
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import android.bluetooth.BluetoothGattDescriptor
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeout
|
||||
|
||||
/** 连接状态机(骨架期最小集,自动重连策略 S3 扩展)。 */
|
||||
sealed interface GattState {
|
||||
data object Connecting : GattState
|
||||
data object Connected : GattState
|
||||
data object ServicesDiscovered : GattState
|
||||
data class Disconnected(val status: Int) : GattState
|
||||
}
|
||||
|
||||
/**
|
||||
* 单设备 GATT 连接会话骨架。
|
||||
*
|
||||
* 用法(S2/S3 业务侧):
|
||||
* ```
|
||||
* val session = connector.connect(scanned)
|
||||
* session.state.filter { it is GattState.ServicesDiscovered }.first()
|
||||
* session.notifyBytes(BleUuids.profileOf(scanned.type)).collect { bytes ->
|
||||
* val readings = parser.feed(bytes)
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
class BleSession internal constructor(
|
||||
context: Context,
|
||||
address: String,
|
||||
) {
|
||||
private val bluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
private val gatt: BluetoothGatt? =
|
||||
bluetoothManager.adapter?.getRemoteDevice(address)?.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
||||
it.connectGatt(context, false, callback, BluetoothGatt.TRANSPORT_LE)
|
||||
} else {
|
||||
it.connectGatt(context, false, callback)
|
||||
}
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<GattState>(GattState.Connecting)
|
||||
|
||||
/** 连接状态流;Disconnected 后会话即终结。 */
|
||||
val state: StateFlow<GattState> = _state
|
||||
|
||||
/** 等待服务发现完成(连接后调用一次)。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
suspend fun awaitServicesDiscovered(timeoutMillis: Long = DEFAULT_TIMEOUT) {
|
||||
withTimeout(timeoutMillis) {
|
||||
state.filter { it != GattState.Connecting && it != GattState.Connected }.first()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅 Notify 特征(写 CCCD 开启通知),返回原始字节流。
|
||||
* 每帧 `0D 0A` 定界由 ble-protocol 解析器负责,本流按 Notify 回调原样发射。
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun notifyBytes(profile: BleUuids.Profile): Flow<ByteArray> {
|
||||
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
|
||||
val characteristic = checkNotNull(
|
||||
gatt.getService(profile.service)?.getCharacteristic(profile.notifyCharacteristic),
|
||||
) { "Notify 特征不存在: ${profile.notifyCharacteristic}" }
|
||||
return callbackFlow {
|
||||
val listener = object : BluetoothGattCallback() {
|
||||
@Deprecated("API 33 起走带 characteristic 参数的新回调;骨架期两版共存")
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
) {
|
||||
if (characteristic.uuid == profile.notifyCharacteristic) {
|
||||
trySend(characteristic.value ?: ByteArray(0))
|
||||
}
|
||||
}
|
||||
}
|
||||
check(gatt.setCharacteristicNotification(characteristic, true)) {
|
||||
"开启 Notify 失败: ${profile.notifyCharacteristic}"
|
||||
}
|
||||
gatt.writeDescriptor(
|
||||
characteristic.getDescriptor(DESCRIPTOR_CCCD)?.apply {
|
||||
value = ENABLE_NOTIFICATION_VALUE
|
||||
},
|
||||
)
|
||||
// 单回调实例仅做字节监听(连接生命周期由主 callback 管理)
|
||||
awaitClose {
|
||||
runCatching {
|
||||
gatt.setCharacteristicNotification(characteristic, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 写特征(VM208 激励命令下发)。返回是否写入成功。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
suspend fun write(profile: BleUuids.Profile, data: ByteArray, timeoutMillis: Long = DEFAULT_TIMEOUT): Boolean {
|
||||
val gatt = checkNotNull(gatt) { "GATT 未初始化" }
|
||||
val characteristic = gatt.getService(profile.service)
|
||||
?.getCharacteristic(profile.writeCharacteristic)
|
||||
?: return false
|
||||
val done = CompletableDeferred<Boolean>()
|
||||
writeListener.completeWith = done
|
||||
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
|
||||
characteristic.value = data
|
||||
if (!gatt.writeCharacteristic(characteristic)) {
|
||||
done.complete(false)
|
||||
}
|
||||
return withTimeout(timeoutMillis) { done.await() }
|
||||
}
|
||||
|
||||
/** 主动断开并释放 GATT 资源。 */
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun disconnect() {
|
||||
gatt?.disconnect()
|
||||
}
|
||||
|
||||
private val callback = object : BluetoothGattCallback() {
|
||||
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||
when (newState) {
|
||||
BluetoothProfile.STATE_CONNECTED -> {
|
||||
_state.value = GattState.Connected
|
||||
gatt.discoverServices()
|
||||
}
|
||||
|
||||
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||
_state.value = GattState.Disconnected(status)
|
||||
gatt.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||
if (status == BluetoothGatt.GATT_SUCCESS) {
|
||||
_state.value = GattState.ServicesDiscovered
|
||||
} else {
|
||||
_state.value = GattState.Disconnected(status)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCharacteristicWrite(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
status: Int,
|
||||
) {
|
||||
writeListener.completeWith.complete(status == BluetoothGatt.GATT_SUCCESS)
|
||||
}
|
||||
}
|
||||
|
||||
/** write 挂起桥:把主 callback 的 onCharacteristicWrite 结果转给调用协程。 */
|
||||
private val writeListener = object {
|
||||
lateinit var completeWith: CompletableDeferred<Boolean>
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DEFAULT_TIMEOUT = 10_000L
|
||||
|
||||
/** Client Characteristic Config 描述符(Notify 开关)。 */
|
||||
val DESCRIPTOR_CCCD: java.util.UUID = java.util.UUID.fromString(
|
||||
"00002902-0000-1000-8000-00805f9b34fb",
|
||||
)
|
||||
val ENABLE_NOTIFICATION_VALUE = byteArrayOf(0x01, 0x00)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GATT 连接入口:按扫描结果建立会话。
|
||||
* S0 骨架不引 Hilt,由 app/S3 按需构造或包装。
|
||||
*/
|
||||
class BleConnector(private val context: Context) {
|
||||
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun connect(scanned: ScannedDevice): BleSession = BleSession(context, scanned.address)
|
||||
}
|
||||
86
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
Normal file
86
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleScanner.kt
Normal file
@@ -0,0 +1,86 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.le.ScanCallback
|
||||
import android.bluetooth.le.ScanResult
|
||||
import android.bluetooth.le.ScanSettings
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresPermission
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
|
||||
/** 一次扫描命中:已按名称前缀过滤为目标设备。 */
|
||||
data class ScannedDevice(
|
||||
val name: String,
|
||||
val address: String,
|
||||
val rssi: Int,
|
||||
val type: DeviceType,
|
||||
)
|
||||
|
||||
/** 扫描失败(硬件不可用/扫描过频等),code 为 ScanCallback.SCAN_FAILED_* 常量。 */
|
||||
class BleScanFailed(val code: Int) : Exception("BLE 扫描失败: code=$code")
|
||||
|
||||
/**
|
||||
* BLE 扫描器:按设备名前缀过滤 IE-1000 / VM208。
|
||||
*
|
||||
* 前缀过滤在内存中做(广播名偶有截断,[ScanFilter.setDeviceNamePrefix]
|
||||
* 在部分机型不可靠),全量回调后按 [DeviceType.fromDeviceName] 筛选。
|
||||
* 权限由 app 壳申请(12L+ 为 BLUETOOTH_SCAN/CONNECT,旧版为 SCAN + 定位)。
|
||||
*/
|
||||
class BleScanner(context: Context) {
|
||||
|
||||
private val bluetoothManager =
|
||||
context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||
|
||||
/**
|
||||
* 开始扫描,命中流自动去重(同地址只保留最近一次)。
|
||||
* 收集端取消或调用 [close] 时停止扫描。
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
@RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
|
||||
fun scan(
|
||||
types: Set<DeviceType> = DeviceType.entries.toSet(),
|
||||
): Flow<ScannedDevice> {
|
||||
val scanner = bluetoothManager.adapter?.bluetoothLeScanner
|
||||
?: throw BleScanFailed(UNAVAILABLE_ADAPTER)
|
||||
return callbackFlow {
|
||||
val settings = ScanSettings.Builder()
|
||||
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||
.build()
|
||||
val callback = object : ScanCallback() {
|
||||
private val seen = HashMap<String, ScannedDevice>()
|
||||
|
||||
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||
val name = result.scanRecord?.deviceName ?: result.device?.name
|
||||
val type = DeviceType.fromDeviceName(name) ?: return
|
||||
if (type !in types) return
|
||||
val device = ScannedDevice(
|
||||
name = name,
|
||||
address = result.device.address,
|
||||
rssi = result.rssi,
|
||||
type = type,
|
||||
)
|
||||
// 同地址覆盖发射:UI 层按 address 收敛列表
|
||||
seen[device.address] = device
|
||||
trySend(device)
|
||||
}
|
||||
|
||||
override fun onScanFailed(errorCode: Int) {
|
||||
close(BleScanFailed(errorCode))
|
||||
}
|
||||
}
|
||||
scanner.startScan(null, settings, callback)
|
||||
awaitClose {
|
||||
runCatching { scanner.stopScan(callback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val UNAVAILABLE_ADAPTER = -100
|
||||
}
|
||||
}
|
||||
42
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
Normal file
42
core/ble/src/main/kotlin/com/stec/cmd/core/ble/BleUuids.kt
Normal file
@@ -0,0 +1,42 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* 两类设备的 GATT UUID 汇总(协议原文 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* ⚠ VM208 服务 UUID 原文档仅 31 位十六进制(`0000fee2000010a0800000602f9b3698`),
|
||||
* 不足标准 128 位(32 位)。此处按末位补 0 占位:
|
||||
* **S2 真机联调时须用 nRF Connect 实测校准**,确认后再更新本文件。
|
||||
*/
|
||||
object BleUuids {
|
||||
|
||||
/** 单设备的 GATT 档案:服务 + 写特征 + Notify 特征。 */
|
||||
data class Profile(
|
||||
val service: UUID,
|
||||
val writeCharacteristic: UUID,
|
||||
val notifyCharacteristic: UUID,
|
||||
)
|
||||
|
||||
/** IE-1000 振弦式读数仪。 */
|
||||
val IE1000: Profile = Profile(
|
||||
service = uuid("0000fee6-0000-2000-6000-006012345678"),
|
||||
writeCharacteristic = uuid("FE000000-0000-0000-0000-000000000580"),
|
||||
notifyCharacteristic = uuid("FE000000-0000-0000-0000-000000000581"),
|
||||
)
|
||||
|
||||
/** VM208 综合采集仪(服务 UUID 见类注释⚠)。 */
|
||||
val VM208: Profile = Profile(
|
||||
service = uuid("0000fee2-0000-10a0-8000-0602f9b36980"),
|
||||
writeCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129700"),
|
||||
notifyCharacteristic = uuid("a44bc439-abfd-45a2-4254-2d4d31129701"),
|
||||
)
|
||||
|
||||
/** 按设备类型取档案。 */
|
||||
fun profileOf(type: DeviceType): Profile = when (type) {
|
||||
DeviceType.IE1000 -> IE1000
|
||||
DeviceType.VM208 -> VM208
|
||||
}
|
||||
|
||||
private fun uuid(value: String): UUID = UUID.fromString(value)
|
||||
}
|
||||
22
core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
Normal file
22
core/ble/src/main/kotlin/com/stec/cmd/core/ble/DeviceType.kt
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.stec.cmd.core.ble
|
||||
|
||||
/**
|
||||
* 平台支持的采集设备类型(协议原文见 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* 蓝牙名以**前缀**匹配:
|
||||
* - IE-1000 振弦式读数仪,广播名 `IE-1000`;
|
||||
* - VM208 综合采集仪,广播名 `VM208 xxxxxx`(带序列号后缀)。
|
||||
*/
|
||||
enum class DeviceType(val namePrefix: String) {
|
||||
IE1000("IE-1000"),
|
||||
VM208("VM208");
|
||||
|
||||
companion object {
|
||||
|
||||
/** 按广播名前缀识别设备类型;无法识别返回 null(外场杂散设备直接忽略)。 */
|
||||
fun fromDeviceName(name: String?): DeviceType? {
|
||||
if (name.isNullOrEmpty()) return null
|
||||
return entries.firstOrNull { name.startsWith(it.namePrefix) }
|
||||
}
|
||||
}
|
||||
}
|
||||
30
core/common/build.gradle.kts
Normal file
30
core/common/build.gradle.kts
Normal file
@@ -0,0 +1,30 @@
|
||||
// core:common 通用基础层:AppResult / UiState / 日志门面
|
||||
// 代码保持纯 Kotlin(不引 Android API),便于 JVM 单测与被所有 core 模块复用
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.common"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.stec.cmd.core.common
|
||||
|
||||
/**
|
||||
* 轻量日志门面:core 层不依赖 android.util.Log,
|
||||
* app 壳启动时注入 LogCat 实现(AppLog.sink = ...),测试环境可不注入或注入内存实现。
|
||||
*/
|
||||
object AppLog {
|
||||
|
||||
enum class Level { DEBUG, INFO, WARN, ERROR }
|
||||
|
||||
fun interface Sink {
|
||||
fun log(level: Level, tag: String, message: String, throwable: Throwable?)
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var sink: Sink? = null
|
||||
|
||||
/** app 启动时调用一次;传 null 恢复为静默(适用于单元测试)。 */
|
||||
fun install(sink: Sink?) {
|
||||
this.sink = sink
|
||||
}
|
||||
|
||||
fun d(tag: String, message: String) = dispatch(Level.DEBUG, tag, message, null)
|
||||
|
||||
fun i(tag: String, message: String) = dispatch(Level.INFO, tag, message, null)
|
||||
|
||||
fun w(tag: String, message: String, throwable: Throwable? = null) =
|
||||
dispatch(Level.WARN, tag, message, throwable)
|
||||
|
||||
fun e(tag: String, message: String, throwable: Throwable? = null) =
|
||||
dispatch(Level.ERROR, tag, message, throwable)
|
||||
|
||||
private fun dispatch(level: Level, tag: String, message: String, throwable: Throwable?) {
|
||||
sink?.log(level, tag, message, throwable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.stec.cmd.core.common
|
||||
|
||||
/**
|
||||
* 全工程统一的结果封装:UI 层与数据层之间只通过 [AppResult] 传递成败,
|
||||
* 具体业务异常(网络/数据库/协议)由各 core 模块定义并在此统一消费。
|
||||
*/
|
||||
sealed interface AppResult<out T> {
|
||||
|
||||
/** 成功,携带业务数据。 */
|
||||
data class Success<T>(val data: T) : AppResult<T>
|
||||
|
||||
/** 失败,携带原始异常与可选的人类可读说明。 */
|
||||
data class Error(
|
||||
val error: Throwable,
|
||||
val message: String? = error.message,
|
||||
) : AppResult<Nothing>
|
||||
|
||||
/** 加载中(首屏/刷新语义由 UI 层区分)。 */
|
||||
data object Loading : AppResult<Nothing>
|
||||
|
||||
companion object {
|
||||
/** 同步块结果捕获:块内抛出的任意异常转为 [Error]。 */
|
||||
inline fun <T> of(block: () -> T): AppResult<T> = try {
|
||||
Success(block())
|
||||
} catch (t: Throwable) {
|
||||
Error(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功值映射,失败/加载态原样透传。 */
|
||||
inline fun <T, R> AppResult<T>.map(transform: (T) -> R): AppResult<R> = when (this) {
|
||||
is AppResult.Success -> AppResult.Success(transform(data))
|
||||
is AppResult.Error -> this
|
||||
is AppResult.Loading -> this
|
||||
}
|
||||
|
||||
/** 成功时的副作用钩子,返回值不变。 */
|
||||
inline fun <T> AppResult<T>.onSuccess(block: (T) -> Unit): AppResult<T> {
|
||||
if (this is AppResult.Success) block(data)
|
||||
return this
|
||||
}
|
||||
|
||||
/** 失败时的副作用钩子,返回值不变。 */
|
||||
inline fun <T> AppResult<T>.onError(block: (AppResult.Error) -> Unit): AppResult<T> {
|
||||
if (this is AppResult.Error) block(this)
|
||||
return this
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.stec.cmd.core.common
|
||||
|
||||
/**
|
||||
* 页面级视图状态:MVVM 中 ViewModel 对 UI 暴露的最小状态机。
|
||||
* 与 [AppResult] 解耦——AppResult 描述一次调用,UiState 描述一个页面。
|
||||
*/
|
||||
sealed interface UiState<out T> {
|
||||
|
||||
/** 初始空闲态(尚未发起加载)。 */
|
||||
data object Idle : UiState<Nothing>
|
||||
|
||||
/** 加载中。 */
|
||||
data object Loading : UiState<Nothing>
|
||||
|
||||
/** 内容就绪。 */
|
||||
data class Success<T>(val data: T) : UiState<T>
|
||||
|
||||
/**
|
||||
* 失败态。
|
||||
*
|
||||
* @property message 用户可读的错误说明
|
||||
* @property retryable 是否允许重试(429/403 等限流场景由业务层定)
|
||||
*/
|
||||
data class Error(
|
||||
val message: String,
|
||||
val retryable: Boolean = true,
|
||||
) : UiState<Nothing>
|
||||
}
|
||||
38
core/database/build.gradle.kts
Normal file
38
core/database/build.gradle.kts
Normal file
@@ -0,0 +1,38 @@
|
||||
// core:database 本地存储:Room AppDatabase + 上传队列(离线优先参考实现)
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.database"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
|
||||
api(libs.androidx.room.runtime)
|
||||
api(libs.androidx.room.ktx)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.stec.cmd.core.database
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
/**
|
||||
* 应用数据库骨架。
|
||||
*
|
||||
* S0 仅注册上传队列一张表作为离线优先参考实现;
|
||||
* S1+ 新业务实体(任务缓存、测点、统计数据等)在 entities 中追加并升 version。
|
||||
* 骨架期 exportSchema=false(无迁移需求);开启迁移管理时再导出 schema。
|
||||
*/
|
||||
@Database(
|
||||
entities = [
|
||||
UploadQueueEntity::class,
|
||||
],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun uploadQueueDao(): UploadQueueDao
|
||||
|
||||
companion object {
|
||||
const val DATABASE_NAME = "stec-cmd.db"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.stec.cmd.core.database
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** 数据库 Hilt 装配:全工程共享一个 AppDatabase。 */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DatabaseModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAppDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): AppDatabase = Room.databaseBuilder(
|
||||
context,
|
||||
AppDatabase::class.java,
|
||||
AppDatabase.DATABASE_NAME,
|
||||
).build()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUploadQueueDao(
|
||||
database: AppDatabase,
|
||||
): UploadQueueDao = database.uploadQueueDao()
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.stec.cmd.core.database
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* 上传队列 DAO:演示离线优先的入队/取待传/回写状态闭环。
|
||||
* S1+ 各数据类型 DAO 照此挂载到 [AppDatabase]。
|
||||
*/
|
||||
@Dao
|
||||
interface UploadQueueDao {
|
||||
|
||||
/** 入队一条待上传数据。 */
|
||||
@Insert
|
||||
suspend fun enqueue(entity: UploadQueueEntity): Long
|
||||
|
||||
/** 按入队顺序观察待上传(含失败待重试)队列。 */
|
||||
@Query(
|
||||
"SELECT * FROM upload_queue " +
|
||||
"WHERE status IN (:statuses) ORDER BY created_at ASC",
|
||||
)
|
||||
fun observeByStatuses(vararg statuses: Int): Flow<List<UploadQueueEntity>>
|
||||
|
||||
/** 一次性取待上传列表(上传调度器使用)。 */
|
||||
@Query(
|
||||
"SELECT * FROM upload_queue " +
|
||||
"WHERE status IN (:statuses) ORDER BY created_at ASC LIMIT :limit",
|
||||
)
|
||||
suspend fun takePending(statuses: List<Int>, limit: Int = DEFAULT_BATCH): List<UploadQueueEntity>
|
||||
|
||||
/** 标记上传中。 */
|
||||
@Query("UPDATE upload_queue SET status = :status WHERE id = :id")
|
||||
suspend fun updateStatus(id: Long, status: Int)
|
||||
|
||||
/** 标记成功并记录时间。 */
|
||||
@Query("UPDATE upload_queue SET status = :status, uploaded_at = :uploadedAt WHERE id = :id")
|
||||
suspend fun markSuccess(id: Long, status: Int, uploadedAt: Long)
|
||||
|
||||
/** 失败重试:状态回待传并累计重试次数。 */
|
||||
@Query(
|
||||
"UPDATE upload_queue SET status = :status, retry_count = retry_count + 1 WHERE id = :id",
|
||||
)
|
||||
suspend fun markRetry(id: Long, status: Int)
|
||||
|
||||
/** 清理已成功且早于截止时间的记录(队列瘦身)。 */
|
||||
@Query("DELETE FROM upload_queue WHERE status = :status AND uploaded_at IS NOT NULL AND uploaded_at < :before")
|
||||
suspend fun purgeSuccessBefore(status: Int, before: Long): Int
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_BATCH = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.stec.cmd.core.database
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* 采集数据上传队列(离线优先参考实现,对应功能明细 M4-07)。
|
||||
*
|
||||
* 设计口径:所有采集数据先入本队列再尝试上传,网络失败留在队列内自动补传;
|
||||
* S1+ 各数据类型(水平位移/测斜/轴力/水位…)照此模式挂载自己的实体,
|
||||
* payloadJson 统一存接口文档约定的上传 JSON 结构。
|
||||
*/
|
||||
@Entity(tableName = "upload_queue")
|
||||
data class UploadQueueEntity(
|
||||
/** 自增主键。 */
|
||||
@PrimaryKey(autoGenerate = true)
|
||||
@ColumnInfo(name = "id")
|
||||
val id: Long = 0L,
|
||||
|
||||
/** 设备类型:IE1000 / VM208(见 core:ble DeviceType)。 */
|
||||
@ColumnInfo(name = "device_type")
|
||||
val deviceType: String,
|
||||
|
||||
/** 设备蓝牙标识(蓝牙名或 MAC)。 */
|
||||
@ColumnInfo(name = "device_id")
|
||||
val deviceId: String,
|
||||
|
||||
/** 业务数据类型(水平位移/测斜/轴力/水位等,S1+ 扩展枚举)。 */
|
||||
@ColumnInfo(name = "data_type")
|
||||
val dataType: String,
|
||||
|
||||
/** 按接口文档组装好的上传 JSON。 */
|
||||
@ColumnInfo(name = "payload_json")
|
||||
val payloadJson: String,
|
||||
|
||||
/** 队列状态,见 [UploadStatus]。 */
|
||||
@ColumnInfo(name = "status")
|
||||
val status: Int = UploadStatus.PENDING,
|
||||
|
||||
/** 已重试次数(429/403 限流退避依据)。 */
|
||||
@ColumnInfo(name = "retry_count")
|
||||
val retryCount: Int = 0,
|
||||
|
||||
/** 入队时间(epoch 毫秒)。 */
|
||||
@ColumnInfo(name = "created_at")
|
||||
val createdAt: Long,
|
||||
|
||||
/** 上传成功时间(epoch 毫秒),未上传为 null。 */
|
||||
@ColumnInfo(name = "uploaded_at")
|
||||
val uploadedAt: Long? = null,
|
||||
)
|
||||
|
||||
/** 队列状态常量(S0 用 Int 避免 Room TypeConverter 样板,S1 可按需替换为枚举)。 */
|
||||
object UploadStatus {
|
||||
/** 待上传。 */
|
||||
const val PENDING = 0
|
||||
|
||||
/** 上传中。 */
|
||||
const val UPLOADING = 1
|
||||
|
||||
/** 上传成功(保留一段时间后由清理任务删除)。 */
|
||||
const val SUCCESS = 2
|
||||
|
||||
/** 失败待重试。 */
|
||||
const val FAILED = 3
|
||||
}
|
||||
48
core/network/build.gradle.kts
Normal file
48
core/network/build.gradle.kts
Normal file
@@ -0,0 +1,48 @@
|
||||
// core:network 网络框架:鉴权三要素拦截器、统一响应解析、Retrofit/OkHttp 装配
|
||||
// NetworkConfig 的实现由 app 壳提供(配置不进代码库,见 M7-04)
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd.core.network"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
// NetworkModule 依据 BuildConfig.DEBUG 决定是否装配 BODY 级日志拦截器
|
||||
buildConfig = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":core:common"))
|
||||
|
||||
// 网络能力对外暴露(业务模块直接声明 Retrofit Service)
|
||||
api(libs.retrofit)
|
||||
api(libs.okhttp)
|
||||
api(libs.kotlinx.serialization.json)
|
||||
implementation(libs.retrofit.converter.kotlinx)
|
||||
implementation(libs.okhttp.logging)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* 平台统一响应包裹(接口文档 1.1 返回状态值约定)。
|
||||
*
|
||||
* - code 为**字符串**:"200" 成功、"500" 失败、"429" 频繁请求超限、"403" 连续报错超限;
|
||||
* - msg:执行成功/失败提示信息;
|
||||
* - data:业务数据,失败时为 null。
|
||||
*/
|
||||
@Serializable
|
||||
data class ApiEnvelope<T>(
|
||||
val code: String,
|
||||
@SerialName("msg") val message: String? = null,
|
||||
val data: T? = null,
|
||||
) {
|
||||
/** 是否业务成功(code = "200")。 */
|
||||
val isSuccess: Boolean get() = code == CODE_SUCCESS
|
||||
|
||||
/** 取业务数据;失败或无数据时抛出对应 [ApiError]。 */
|
||||
fun bodyOrThrow(): T {
|
||||
if (!isSuccess) throw ApiError.fromCode(code, message)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return data as? T ?: throw ApiError.EmptyBodyError(message)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 接口请求成功。 */
|
||||
const val CODE_SUCCESS = "200"
|
||||
|
||||
/** 接口请求失败。 */
|
||||
const val CODE_SERVER_ERROR = "500"
|
||||
|
||||
/** 同一条数据一分钟内频繁请求,超过允许最大次数。 */
|
||||
const val CODE_TOO_MANY_REQUESTS = "429"
|
||||
|
||||
/** 同一条数据连续报错,超过允许出错最大次数。 */
|
||||
const val CODE_FORBIDDEN = "403"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
/**
|
||||
* 平台接口错误体系:UI 层只需捕获 [ApiError] 并按子类给出提示。
|
||||
*
|
||||
* 语义对齐接口文档 1.1:
|
||||
* - 500 → [ServerError] 业务失败;
|
||||
* - 429 → [TooManyRequests] 一分钟内同一条数据频繁请求超限;
|
||||
* - 403 → [Locked] 同一条数据连续报错超限(平台锁定,非 HTTP 403 权限语义);
|
||||
* - HTTP 层失败(超时/断网)→ [NetworkError]。
|
||||
*/
|
||||
sealed class ApiError(
|
||||
message: String?,
|
||||
cause: Throwable? = null,
|
||||
) : Exception(message, cause) {
|
||||
|
||||
/** 服务器业务失败(响应 code = 500)。 */
|
||||
class ServerError(
|
||||
val code: String,
|
||||
message: String?,
|
||||
) : ApiError(message)
|
||||
|
||||
/** 请求过频(响应 code = 429)。 */
|
||||
class TooManyRequests(
|
||||
message: String?,
|
||||
) : ApiError(message ?: DEFAULT_TOO_MANY_MESSAGE)
|
||||
|
||||
/** 连续报错被平台锁定(响应 code = 403)。 */
|
||||
class Locked(
|
||||
message: String?,
|
||||
) : ApiError(message ?: DEFAULT_LOCKED_MESSAGE)
|
||||
|
||||
/** 成功码但 data 缺失(服务端契约异常)。 */
|
||||
class EmptyBodyError(
|
||||
message: String?,
|
||||
) : ApiError(message ?: "响应成功但缺少数据")
|
||||
|
||||
/** 网络/超时等传输层失败。 */
|
||||
class NetworkError(
|
||||
cause: Throwable,
|
||||
) : ApiError(cause.message, cause)
|
||||
|
||||
companion object {
|
||||
private const val DEFAULT_TOO_MANY_MESSAGE = "操作过于频繁,请稍后再试"
|
||||
private const val DEFAULT_LOCKED_MESSAGE = "连续失败次数过多,请稍后再试"
|
||||
|
||||
/** 按响应体 code 映射业务错误。 */
|
||||
fun fromCode(code: String, message: String?): ApiError = when (code) {
|
||||
ApiEnvelope.CODE_TOO_MANY_REQUESTS -> TooManyRequests(message)
|
||||
ApiEnvelope.CODE_FORBIDDEN -> Locked(message)
|
||||
else -> ServerError(code, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* 鉴权三要素拦截器(接口文档 1.2):为每个请求注入请求头。
|
||||
*
|
||||
* - `SecretKey`:供应商密钥,必带;
|
||||
* - `SystemCode`:供应商编码,必带;
|
||||
* - `Token`:登录后颁发;[TokenProvider.currentToken] 为 null 时跳过,
|
||||
* 登录/验证码等未登录接口天然不携带。
|
||||
*/
|
||||
class AuthInterceptor @Inject constructor(
|
||||
private val config: NetworkConfig,
|
||||
) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request().newBuilder()
|
||||
.header(HEADER_SECRET_KEY, config.secretKey)
|
||||
.header(HEADER_SYSTEM_CODE, config.systemCode)
|
||||
.apply {
|
||||
config.tokenProvider.currentToken()?.let { token ->
|
||||
header(HEADER_TOKEN, token)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HEADER_SECRET_KEY = "SecretKey"
|
||||
const val HEADER_SYSTEM_CODE = "SystemCode"
|
||||
const val HEADER_TOKEN = "Token"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
/**
|
||||
* 网络配置抽象:baseUrl 与鉴权三要素的提供者。
|
||||
*
|
||||
* 接口文档 1.2 约定:
|
||||
* - SecretKey:供应商调用密钥,会定期更换,必须做成配置项(M7-04);
|
||||
* - SystemCode:供应商编码,与 SecretKey 一一对应;
|
||||
* - Token:登录后颁发,除登录接口外必须携带。
|
||||
*
|
||||
* 实现由 app 壳注入(Hilt @Binds),值来自加密配置存储,禁止硬编码进代码库。
|
||||
*/
|
||||
interface NetworkConfig {
|
||||
|
||||
/** 平台接口基地址(例:https://jcd.stec.p-q.co/)。 */
|
||||
val baseUrl: String
|
||||
|
||||
/** 供应商调用密钥。 */
|
||||
val secretKey: String
|
||||
|
||||
/** 供应商编码。 */
|
||||
val systemCode: String
|
||||
|
||||
/** Token 快照提供者。 */
|
||||
val tokenProvider: TokenProvider
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 快照提供者。
|
||||
*
|
||||
* 拦截器在 OkHttp 后台线程同步读取,实现方应返回内存缓存值
|
||||
* (app 侧登录/登出/刷新时更新缓存 + 持久化),避免每次请求读盘。
|
||||
*/
|
||||
interface TokenProvider {
|
||||
|
||||
/** 当前 Token;未登录返回 null(此时请求不携带 Token 头)。 */
|
||||
fun currentToken(): String?
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.stec.cmd.core.network
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 网络层 Hilt 装配。
|
||||
*
|
||||
* [NetworkConfig] 的实现由 app 壳通过 @Binds 提供(配置不进代码库);
|
||||
* Retrofit Service 接口在 S1+ 各业务模块中声明后直接注入使用。
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object NetworkModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideJson(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(
|
||||
config: NetworkConfig,
|
||||
): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.addInterceptor(AuthInterceptor(config))
|
||||
|
||||
// 外业调试期保留 BODY 级日志;发布构建由 proguard 移除该拦截器装配
|
||||
if (BuildConfig.DEBUG) {
|
||||
val logging = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BODY
|
||||
}
|
||||
builder.addInterceptor(logging)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetrofit(
|
||||
client: OkHttpClient,
|
||||
json: Json,
|
||||
config: NetworkConfig,
|
||||
): Retrofit = Retrofit.Builder()
|
||||
.baseUrl(config.baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
private const val CONNECT_TIMEOUT_SECONDS = 15L
|
||||
private const val READ_TIMEOUT_SECONDS = 30L
|
||||
private const val WRITE_TIMEOUT_SECONDS = 30L
|
||||
}
|
||||
Reference in New Issue
Block a user