untracked files on chat/0b04c8c0-be30-4414-9dad-b7be82b2619a: 34b2e86 init
This commit is contained in:
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
# Gradle
|
||||
.gradle/
|
||||
build/
|
||||
!gradle/wrapper/gradle-wrapper.jar
|
||||
!**/src/main/**/build/
|
||||
!**/src/test/**/build/
|
||||
|
||||
# Android Studio / IntelliJ
|
||||
.idea/
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
captures/
|
||||
.externalNativeBuild/
|
||||
.cxx/
|
||||
|
||||
# 本地环境配置(不进入版本库)
|
||||
local.properties
|
||||
|
||||
# 密钥与签名(M7-04:SecretKey/SystemCode/keystore 一律不入库)
|
||||
*.jks
|
||||
*.keystore
|
||||
secrets.properties
|
||||
keystore.properties
|
||||
|
||||
# 日志与临时文件
|
||||
*.log
|
||||
.DS_Store
|
||||
53
README.md
Normal file
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 监测数据不落地平台 · 安卓客户端
|
||||
|
||||
外业监测数据「不落地」采集与上传工具:现场用手机通过蓝牙连接振弦式读数仪(IE-1000)与综合采集仪(VM208),解析采集数据并直接上传至院方监测平台,配合离线队列保证弱网/断网可用。
|
||||
|
||||
> 本文件为 2026-09-03 重建版(基线文件曾随工作树清理丢失,按任务记录复刻)。
|
||||
|
||||
## 技术基线
|
||||
|
||||
| 项 | 选型 |
|
||||
|---|---|
|
||||
| 语言 / UI | Kotlin 2.0.21、View 体系 + Material 3 |
|
||||
| 架构 | MVVM(ViewModel + StateFlow/LiveData) |
|
||||
| 网络 | Retrofit 2.11 + OkHttp 4.12 + kotlinx.serialization 1.7.3 |
|
||||
| 本地存储 | Room 2.6.1(离线优先队列)+ DataStore 1.1.1 + EncryptedSharedPreferences |
|
||||
| 依赖注入 | Hilt 2.52 |
|
||||
| 蓝牙 | Android 原生 BLE API;协议解析独立纯 Kotlin 模块 |
|
||||
| 构建 | AGP 8.7.3 / Gradle 8.10.2 / JDK 17 / minSdk 26 / targetSdk 34 |
|
||||
|
||||
## 模块结构
|
||||
|
||||
```
|
||||
app 业务壳:导航、主题、权限、配置注入(M1–M6 页面挂载点)
|
||||
core
|
||||
├ common AppResult / UiState / 日志门面(纯 Kotlin 优先)
|
||||
├ network 鉴权三要素拦截器、ApiEnvelope 统一解析、Retrofit 装配
|
||||
├ database Room AppDatabase + 上传队列(离线优先参考实现)
|
||||
└ ble BLE 扫描(按名称前缀过滤)、GATT 连接封装骨架
|
||||
ble-protocol 纯 Kotlin JVM:IE-1000 / VM208 帧解析(零 Android 依赖,可独立单测)
|
||||
```
|
||||
|
||||
依赖方向:`app → core:* + ble-protocol`;`core:{network,database,ble} → core:common`;`ble-protocol` 零依赖。
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. Android Studio(Ladybug+,JDK 17)打开工程根目录,等待 Gradle Sync;
|
||||
2. 服务器地址、SecretKey、SystemCode 通过 `app` 壳的配置仓储注入(见 `core:network` 的 `NetworkConfig`),**一律不写入代码库**;
|
||||
3. 运行:`./gradlew :app:assembleDebug`;协议单测:`./gradlew :ble-protocol:test`。
|
||||
|
||||
## 配置说明
|
||||
|
||||
| 配置项 | 说明 | 注入点 |
|
||||
|---|---|---|
|
||||
| baseUrl | 平台接口地址(测试环境见接口文档 1.2.4) | ConfigRepository → NetworkConfig |
|
||||
| SecretKey | 供应商调用密钥(会定期更换,必须可配置) | 同上 |
|
||||
| SystemCode | 供应商编码(与 SecretKey 一一对应) | 同上 |
|
||||
| Token | 登录后颁发,登录接口外必带 | TokenProvider(DataStore) |
|
||||
|
||||
## 文档索引
|
||||
|
||||
- `docs/安卓客户端功能明细表.md` —— 36 个功能点、模块映射、优先级、接口覆盖对照
|
||||
- `docs/安卓客户端开发计划表.md` —— 6 阶段 12 周迭代计划、人日估算、里程碑、风险
|
||||
- `docs/BLE-IE1000.txt` —— IE-1000 / VM208 蓝牙协议原始说明
|
||||
- 接口规范 V4.5 —— 上海城建勘测院《监测数据不落地系统接口文档》(docs/ 下 docx)
|
||||
74
app/build.gradle.kts
Normal file
74
app/build.gradle.kts
Normal file
@@ -0,0 +1,74 @@
|
||||
// app 业务壳:导航、主题、权限、配置注入;M1–M6 页面经 FeatureMount 挂载
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.stec.cmd"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.stec.cmd"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0-S0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// core 基础层 + 协议解析(业务模块只允许经 core 层访问能力)
|
||||
implementation(project(":core:common"))
|
||||
implementation(project(":core:network"))
|
||||
implementation(project(":core:database"))
|
||||
implementation(project(":core:ble"))
|
||||
implementation(project(":ble-protocol"))
|
||||
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.fragment.ktx)
|
||||
implementation(libs.androidx.splashscreen)
|
||||
implementation(libs.google.material)
|
||||
|
||||
implementation(libs.androidx.lifecycle.runtime)
|
||||
implementation(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.androidx.lifecycle.livedata)
|
||||
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.security.crypto)
|
||||
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
}
|
||||
12
app/proguard-rules.pro
vendored
Normal file
12
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# app 壳混淆规则(release)
|
||||
# S0 骨架期保持最小规则集,S5 发布阶段随业务实体补全
|
||||
|
||||
# kotlinx.serialization:保留序列化器生成逻辑(S1 业务 DTO 接入后按类型名细化)
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
-dontnote kotlinx.serialization.AnnotationsKt
|
||||
|
||||
# OkHttp / Retrofit
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn javax.annotation.**
|
||||
-keepattributes Signature, Exceptions
|
||||
54
app/src/main/AndroidManifest.xml
Normal file
54
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 蓝牙:Android 12L+ -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
|
||||
android:usesPermissionFlags="neverForLocation"
|
||||
tools:targetApi="s" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
|
||||
<!-- 蓝牙:Android 12L 以下(随 12L+ 权限一同声明,按系统版本生效) -->
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"
|
||||
android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"
|
||||
android:maxSdkVersion="30" />
|
||||
<!-- 12L 以下 BLE 扫描依赖定位权限 -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="30" />
|
||||
|
||||
<!-- 通知(33+,上传进度/采集会话通知预留) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- 网络(core:network) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!-- 蓝牙为可选硬件:无蓝牙设备仍可安装 -->
|
||||
<uses-feature android:name="android.hardware.bluetooth_le"
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".SteCmdApplication"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.SteCmd"
|
||||
tools:targetApi="34">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Theme.SteCmd.Splash">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
7
app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/CollectFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 采集占位(M4+M5 数据采集,S2/S3 挂载扫描与采集页)。 */
|
||||
@AndroidEntryPoint
|
||||
class CollectFragment : PlaceholderFragment()
|
||||
41
app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
Normal file
41
app/src/main/kotlin/com/stec/cmd/FeatureMount.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
package com.stec.cmd
|
||||
|
||||
/**
|
||||
* 底部导航 5 Tab 与业务模块(功能明细表编号)的挂载点注册中心。
|
||||
*
|
||||
* S1+ 挂载业务页面的方式:把对应 [MountItem.fragmentClass] 从占位 Fragment
|
||||
* 换成业务 Fragment 即可,导航壳(MainActivity)零改动;新增 Tab 时扩展 [Tab]。
|
||||
* 挂载映射依据《安卓客户端功能明细表》:
|
||||
* 首页=M2、任务=M3、采集=M4+M5、统计=M6、我的=M1。
|
||||
*/
|
||||
object FeatureMount {
|
||||
|
||||
/** 底部导航 Tab(itemId 与 res/menu/bottom_nav_menu.xml 一一对应)。 */
|
||||
enum class Tab(val itemId: Int, val labelRes: Int) {
|
||||
HOME(R.id.nav_home, R.string.tab_home),
|
||||
TASK(R.id.nav_task, R.string.tab_task),
|
||||
COLLECT(R.id.nav_collect, R.string.tab_collect),
|
||||
STATS(R.id.nav_stats, R.string.tab_stats),
|
||||
MINE(R.id.nav_mine, R.string.tab_mine),
|
||||
}
|
||||
|
||||
/** 单个 Tab 的挂载项。 */
|
||||
data class MountItem(
|
||||
val tab: Tab,
|
||||
val fragmentClass: Class<out androidx.fragment.app.Fragment>,
|
||||
val moduleTag: String,
|
||||
)
|
||||
|
||||
/** 挂载注册表:S1+ 替换 fragmentClass 即完成业务页挂载。 */
|
||||
val mounts: List<MountItem> = listOf(
|
||||
MountItem(Tab.HOME, HomeFragment::class.java, "M2 项目工作台"),
|
||||
MountItem(Tab.TASK, TaskFragment::class.java, "M3 任务管理"),
|
||||
MountItem(Tab.COLLECT, CollectFragment::class.java, "M4+M5 数据采集"),
|
||||
MountItem(Tab.STATS, StatsFragment::class.java, "M6 统计与上传"),
|
||||
MountItem(Tab.MINE, MineFragment::class.java, "M1 我的"),
|
||||
)
|
||||
|
||||
/** 按 menu itemId 取挂载项;未知 id 返回 null(容错)。 */
|
||||
fun byItemId(itemId: Int): MountItem? =
|
||||
mounts.firstOrNull { it.tab.itemId == itemId }
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/HomeFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 首页占位(M2 项目工作台,S1 挂载项目列表)。 */
|
||||
@AndroidEntryPoint
|
||||
class HomeFragment : PlaceholderFragment()
|
||||
59
app/src/main/kotlin/com/stec/cmd/MainActivity.kt
Normal file
59
app/src/main/kotlin/com/stec/cmd/MainActivity.kt
Normal file
@@ -0,0 +1,59 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.commit
|
||||
import com.stec.cmd.databinding.ActivityMainBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/**
|
||||
* 单 Activity 导航壳:BottomNavigationView 5 Tab 切换 Fragment。
|
||||
* show/hide 复用实例以保留各 Tab 视图状态;S1 可迁移 Navigation 组件。
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityMainBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
binding.bottomNav.setOnItemSelectedListener(::selectTab)
|
||||
if (savedInstanceState == null) {
|
||||
binding.bottomNav.selectedItemId = FeatureMount.Tab.HOME.itemId
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectTab(item: MenuItem): Boolean {
|
||||
val mount = FeatureMount.byItemId(item.itemId) ?: return false
|
||||
switchTo(mount)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun switchTo(mount: FeatureMount.MountItem) {
|
||||
val tag = mount.tab.name
|
||||
val existing = supportFragmentManager.findFragmentByTag(tag)
|
||||
val target: Fragment = existing ?: createFragment(mount)
|
||||
|
||||
supportFragmentManager.commit {
|
||||
setReorderingAllowed(true)
|
||||
supportFragmentManager.fragments.forEach { if (it !== target) hide(it) }
|
||||
if (existing == null) add(R.id.fragment_container, target, tag)
|
||||
show(target)
|
||||
}
|
||||
supportActionBar?.title = getString(mount.tab.labelRes)
|
||||
}
|
||||
|
||||
/** 实例化挂载项声明的 Fragment;占位 Fragment 支持模块名参数。 */
|
||||
private fun createFragment(mount: FeatureMount.MountItem): Fragment {
|
||||
val fragment = mount.fragmentClass.getDeclaredConstructor().newInstance()
|
||||
if (fragment is PlaceholderFragment) {
|
||||
fragment.arguments = PlaceholderFragment.args(mount.moduleTag)
|
||||
}
|
||||
return fragment
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/MineFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/MineFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 我的占位(M1 登录与配置,S1 挂载登录/配置页)。 */
|
||||
@AndroidEntryPoint
|
||||
class MineFragment : PlaceholderFragment()
|
||||
45
app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
Normal file
45
app/src/main/kotlin/com/stec/cmd/PlaceholderFragment.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.stec.cmd.databinding.FragmentPlaceholderBinding
|
||||
|
||||
/**
|
||||
* S0 占位页基类:显示模块编号与挂载说明,S1+ 由各业务 Fragment 替换。
|
||||
*/
|
||||
open class PlaceholderFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPlaceholderBinding? = null
|
||||
private val binding get() = checkNotNull(_binding)
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?,
|
||||
): View {
|
||||
_binding = FragmentPlaceholderBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val moduleTag = arguments?.getString(ARG_MODULE_TAG).orEmpty()
|
||||
binding.moduleTag.text = moduleTag
|
||||
binding.hint.text = getString(R.string.placeholder_hint)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ARG_MODULE_TAG = "module_tag"
|
||||
|
||||
fun args(moduleTag: String): Bundle = Bundle().apply {
|
||||
putString(ARG_MODULE_TAG, moduleTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/StatsFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 统计占位(M6 统计与上传,S4 挂载统计表与文件上传)。 */
|
||||
@AndroidEntryPoint
|
||||
class StatsFragment : PlaceholderFragment()
|
||||
31
app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
Normal file
31
app/src/main/kotlin/com/stec/cmd/SteCmdApplication.kt
Normal file
@@ -0,0 +1,31 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import com.stec.cmd.core.common.AppLog
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
/**
|
||||
* 应用入口:装配 Hilt 图并安装日志实现。
|
||||
* core:common 的 AppLog 门面不依赖 android.util.Log,LogCat sink 在壳层注入。
|
||||
*/
|
||||
@HiltAndroidApp
|
||||
class SteCmdApplication : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
AppLog.install { level, tag, message, throwable ->
|
||||
when (level) {
|
||||
AppLog.Level.DEBUG -> Log.d(tag, message, throwable)
|
||||
AppLog.Level.INFO -> Log.i(tag, message, throwable)
|
||||
AppLog.Level.WARN -> Log.w(tag, message, throwable)
|
||||
AppLog.Level.ERROR -> Log.e(tag, message, throwable)
|
||||
}
|
||||
}
|
||||
AppLog.i(TAG, "监测数据不落地平台客户端启动(S0 骨架)")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SteCmd"
|
||||
}
|
||||
}
|
||||
7
app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
Normal file
7
app/src/main/kotlin/com/stec/cmd/TaskFragment.kt
Normal file
@@ -0,0 +1,7 @@
|
||||
package com.stec.cmd
|
||||
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
|
||||
/** 任务占位(M3 任务管理,S2 挂载任务列表)。 */
|
||||
@AndroidEntryPoint
|
||||
class TaskFragment : PlaceholderFragment()
|
||||
49
app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
Normal file
49
app/src/main/kotlin/com/stec/cmd/config/AppModule.kt
Normal file
@@ -0,0 +1,49 @@
|
||||
package com.stec.cmd.config
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.stec.cmd.core.network.NetworkConfig
|
||||
import com.stec.cmd.core.network.TokenProvider
|
||||
import dagger.Binds
|
||||
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
|
||||
|
||||
private val Context.configDataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "stec_cmd_config",
|
||||
)
|
||||
|
||||
/**
|
||||
* app 壳 Hilt 装配:
|
||||
* - DataStore 实例;
|
||||
* - [ConfigRepository] 绑定为 core:network 的 [NetworkConfig] / [TokenProvider],
|
||||
* 补全 core:network NetworkModule 的依赖图(密钥值由用户配置注入,不入库)。
|
||||
*/
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideConfigDataStore(
|
||||
@ApplicationContext context: Context,
|
||||
): DataStore<Preferences> = context.configDataStore
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ConfigBindModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindNetworkConfig(impl: ConfigRepository): NetworkConfig
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindTokenProvider(impl: ConfigRepository): TokenProvider
|
||||
}
|
||||
127
app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
Normal file
127
app/src/main/kotlin/com/stec/cmd/config/ConfigRepository.kt
Normal file
@@ -0,0 +1,127 @@
|
||||
package com.stec.cmd.config
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import com.stec.cmd.core.common.AppLog
|
||||
import com.stec.cmd.core.network.NetworkConfig
|
||||
import com.stec.cmd.core.network.TokenProvider
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* 平台配置仓储(M1-06 前置、M7-04 落地):
|
||||
* baseUrl / SecretKey / SystemCode / Token 经 DataStore 持久化 + 内存快照。
|
||||
*
|
||||
* **默认值全空,密钥不进代码库**:S1 在「我的」页面提供配置 UI;
|
||||
* 未配置时 [NetworkConfig] 各字段为空串,AuthInterceptor 将带空三要素,
|
||||
* 业务请求由 S1 的配置检查(未配置 → 跳转配置页)拦截。
|
||||
*
|
||||
* 拦截器在 OkHttp 后台线程同步读快照(runBlocking 只在冷启动首读时短暂阻塞,
|
||||
* 之后 [snapshotStateFlow] 已被预热,直接返回缓存值)。
|
||||
*/
|
||||
@Singleton
|
||||
class ConfigRepository @Inject constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) : NetworkConfig, TokenProvider {
|
||||
|
||||
data class Snapshot(
|
||||
val baseUrl: String = "",
|
||||
val secretKey: String = "",
|
||||
val systemCode: String = "",
|
||||
val token: String = "",
|
||||
) {
|
||||
/** 三要素 + 地址是否已配置完整。 */
|
||||
val isReady: Boolean
|
||||
get() = baseUrl.isNotBlank() && secretKey.isNotBlank() && systemCode.isNotBlank()
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
private val snapshotFlow = MutableStateFlow(Snapshot())
|
||||
|
||||
/** 配置快照流:S1 配置页/业务层订阅。 */
|
||||
val snapshotStateFlow: StateFlow<Snapshot> = snapshotFlow.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
dataStore.data
|
||||
.catch { e ->
|
||||
if (e is IOException) emit(emptyPreferences()) else throw e
|
||||
}
|
||||
.collect { prefs ->
|
||||
snapshotFlow.value = Snapshot(
|
||||
baseUrl = prefs[KEY_BASE_URL].orEmpty(),
|
||||
secretKey = prefs[KEY_SECRET_KEY].orEmpty(),
|
||||
systemCode = prefs[KEY_SYSTEM_CODE].orEmpty(),
|
||||
token = prefs[KEY_TOKEN].orEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NetworkConfig ----
|
||||
|
||||
override val baseUrl: String get() = snapshot().baseUrl
|
||||
|
||||
override val secretKey: String get() = snapshot().secretKey
|
||||
|
||||
override val systemCode: String get() = snapshot().systemCode
|
||||
|
||||
override val tokenProvider: TokenProvider get() = this
|
||||
|
||||
// ---- TokenProvider ----
|
||||
|
||||
override fun currentToken(): String = snapshot().token
|
||||
|
||||
// ---- 写入(S1 配置页 / 登录流程调用)----
|
||||
|
||||
/** 保存三要素与服务器地址。 */
|
||||
suspend fun updateConnection(baseUrl: String, secretKey: String, systemCode: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
|
||||
prefs[KEY_SECRET_KEY] = secretKey.trim()
|
||||
prefs[KEY_SYSTEM_CODE] = systemCode.trim()
|
||||
}
|
||||
AppLog.i(TAG, "连接配置已更新")
|
||||
}
|
||||
|
||||
/** 登录成功保存 Token;登出传空串。 */
|
||||
suspend fun updateToken(token: String) {
|
||||
dataStore.edit { it[KEY_TOKEN] = token }
|
||||
}
|
||||
|
||||
/** 阻塞读取当前快照(拦截器线程安全:值来自内存缓存)。 */
|
||||
private fun snapshot(): Snapshot =
|
||||
if (snapshotFlow.value != Snapshot()) {
|
||||
snapshotFlow.value
|
||||
} else {
|
||||
// 冷启动首读:预热缓存
|
||||
runBlocking { snapshotFlow.first() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "ConfigRepository"
|
||||
val KEY_BASE_URL = stringPreferencesKey("base_url")
|
||||
val KEY_SECRET_KEY = stringPreferencesKey("secret_key")
|
||||
val KEY_SYSTEM_CODE = stringPreferencesKey("system_code")
|
||||
val KEY_TOKEN = stringPreferencesKey("token")
|
||||
}
|
||||
}
|
||||
|
||||
/** 只读 Token 快照接口的窄化视图(给不需要完整 NetworkConfig 的组件用)。 */
|
||||
val ConfigRepository.tokenFlow: Flow<Snapshot> get() = snapshotStateFlow
|
||||
7
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
7
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="@color/launcher_background"
|
||||
android:pathData="M0,0 L108,0 L108,108 L0,108 Z" />
|
||||
</vector>
|
||||
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
13
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp" android:height="108dp"
|
||||
android:viewportWidth="108" android:viewportHeight="108">
|
||||
<!-- 蓝牙风格前景:安全区中央(内容控制在 66dp 内圆内) -->
|
||||
<path
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeWidth="4"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M54,32 L54,76 M54,32 L64,41.5 L44,66.5 M54,76 L64,66.5 L44,41.5" />
|
||||
</vector>
|
||||
12
app/src/main/res/drawable/ic_nav_collect.xml
Normal file
12
app/src/main/res/drawable/ic_nav_collect.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<!-- 蓝牙符号:采集 Tab -->
|
||||
<path android:strokeColor="#FF000000"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M12,2.5 L12,21.5 M12,2.5 L16.5,6.8 L7.5,17.2 M12,21.5 L16.5,17.2 L7.5,6.8" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_home.xml
Normal file
7
app/src/main/res/drawable/ic_nav_home.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000"
|
||||
android:pathData="M12,3 L4,10 L4,21 L10,21 L10,15 L14,15 L14,21 L20,21 L20,10 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_mine.xml
Normal file
7
app/src/main/res/drawable/ic_nav_mine.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000"
|
||||
android:pathData="M12,12 C14.2,12 16,10.2 16,8 C16,5.8 14.2,4 12,4 C9.8,4 8,5.8 8,8 C8,10.2 9.8,12 12,12 Z M4,20 C4,16.7 7.6,14 12,14 C16.4,14 20,16.7 20,20 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_stats.xml
Normal file
7
app/src/main/res/drawable/ic_nav_stats.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000"
|
||||
android:pathData="M5,20 L5,12 L8,12 L8,20 Z M11,20 L11,6 L14,6 L14,20 Z M17,20 L17,9 L20,9 L20,20 Z" />
|
||||
</vector>
|
||||
7
app/src/main/res/drawable/ic_nav_task.xml
Normal file
7
app/src/main/res/drawable/ic_nav_task.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp" android:height="24dp"
|
||||
android:viewportWidth="24" android:viewportHeight="24">
|
||||
<path android:fillColor="#FF000000"
|
||||
android:pathData="M4,5 L20,5 L20,7 L4,7 Z M4,11 L20,11 L20,13 L4,13 Z M4,17 L14,17 L14,19 L4,19 Z" />
|
||||
</vector>
|
||||
22
app/src/main/res/layout/activity_main.xml
Normal file
22
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.fragment.app.FragmentContainerView
|
||||
android:id="@+id/fragment_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<com.google.android.material.bottomnavigation.BottomNavigationView
|
||||
android:id="@+id/bottom_nav"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
app:labelVisibilityMode="labeled"
|
||||
app:menu="@menu/bottom_nav_menu" />
|
||||
|
||||
</LinearLayout>
|
||||
34
app/src/main/res/layout/fragment_placeholder.xml
Normal file
34
app/src/main/res/layout/fragment_placeholder.xml
Normal file
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:padding="24dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/module_tag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@id/hint"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintVertical_chainStyle="packed" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/hint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:textSize="14sp"
|
||||
android:alpha="0.7"
|
||||
app:layout_constraintTop_toBottomOf="@id/module_tag"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
23
app/src/main/res/menu/bottom_nav_menu.xml
Normal file
23
app/src/main/res/menu/bottom_nav_menu.xml
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:id="@+id/nav_home"
|
||||
android:icon="@drawable/ic_nav_home"
|
||||
android:title="@string/tab_home" />
|
||||
<item
|
||||
android:id="@+id/nav_task"
|
||||
android:icon="@drawable/ic_nav_task"
|
||||
android:title="@string/tab_task" />
|
||||
<item
|
||||
android:id="@+id/nav_collect"
|
||||
android:icon="@drawable/ic_nav_collect"
|
||||
android:title="@string/tab_collect" />
|
||||
<item
|
||||
android:id="@+id/nav_stats"
|
||||
android:icon="@drawable/ic_nav_stats"
|
||||
android:title="@string/tab_stats" />
|
||||
<item
|
||||
android:id="@+id/nav_mine"
|
||||
android:icon="@drawable/ic_nav_mine"
|
||||
android:title="@string/tab_mine" />
|
||||
</menu>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
9
app/src/main/res/values/colors.xml
Normal file
9
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- 品牌色占位(S1 视觉规范化时校准) -->
|
||||
<color name="brand_primary">#1E6FD9</color>
|
||||
<color name="brand_on_primary">#FFFFFF</color>
|
||||
<color name="brand_background">#F7F9FC</color>
|
||||
<color name="brand_surface">#FFFFFF</color>
|
||||
<color name="launcher_background">#1E6FD9</color>
|
||||
</resources>
|
||||
12
app/src/main/res/values/strings.xml
Normal file
12
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">监测数据不落地</string>
|
||||
|
||||
<string name="tab_home">首页</string>
|
||||
<string name="tab_task">任务</string>
|
||||
<string name="tab_collect">采集</string>
|
||||
<string name="tab_stats">统计</string>
|
||||
<string name="tab_mine">我的</string>
|
||||
|
||||
<string name="placeholder_hint">占位页 —— 业务功能按 S1–S4 计划经 FeatureMount 挂载</string>
|
||||
</resources>
|
||||
20
app/src/main/res/values/themes.xml
Normal file
20
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- 应用主主题:Material 3 DayNight,无标题栏(自管理标题) -->
|
||||
<style name="Theme.SteCmd" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="colorPrimary">@color/brand_primary</item>
|
||||
<item name="colorOnPrimary">@color/brand_on_primary</item>
|
||||
<item name="android:colorBackground">@color/brand_background</item>
|
||||
<item name="colorSurface">@color/brand_surface</item>
|
||||
<item name="android:statusBarColor">@color/brand_background</item>
|
||||
<item name="android:windowLightStatusBar" tools:targetApi="m">true</item>
|
||||
</style>
|
||||
|
||||
<!-- 启动闪屏(core-splashscreen 兼容 12 以下) -->
|
||||
<style name="Theme.SteCmd.Splash" parent="Theme.SplashScreen">
|
||||
<item name="windowSplashScreenBackground">@color/launcher_background</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/ic_launcher_foreground</item>
|
||||
<item name="postSplashScreenTheme">@style/Theme.SteCmd</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
app/src/main/res/xml/network_security_config.xml
Normal file
18
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
网络安全白名单:
|
||||
- 默认全域强制 HTTPS(usesCleartextTraffic=false 于 Manifest);
|
||||
- 仅对接口文档 1.2.4 测试环境 IP 开放明文 HTTP(外场联调),发布前如生产全 HTTPS 可移除;
|
||||
- 测试域名 jcd.stec.p-q.co 为 HTTPS,无需额外配置。
|
||||
-->
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">106.15.183.20</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
13
ble-protocol/build.gradle.kts
Normal file
13
ble-protocol/build.gradle.kts
Normal file
@@ -0,0 +1,13 @@
|
||||
// ble-protocol:纯 Kotlin JVM 模块,零第三方依赖(测试框架除外)
|
||||
// 职责:IE-1000 / VM208 蓝牙帧的解析与编码,脱离 Android 可独立单测
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.stec.cmd.protocol.ie1000
|
||||
|
||||
/**
|
||||
* IE-1000 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* 帧格式(固定 12 字节):
|
||||
* ```
|
||||
* 'Y'(0x59) P0 P1 P2 P3 'T'(0x54) T0 T1 T2 T3 0x0D 0x0A
|
||||
* ```
|
||||
* 其中压力 4 字节、温度 4 字节均为小端 IEEE-754 float。
|
||||
* 协议示例:`59 D8 BE 50 41 54 00 00 E8 41 0D 0A` → 压力 13.04、温度 29.0。
|
||||
*
|
||||
* BLE Notify 每次回调的字节数不定,解析器内部维护字节缓冲,
|
||||
* [feed] 可按任意分片反复调用,按 `0D 0A` 定界切帧;
|
||||
* 切出但不合规格式的帧(长度/帧头不符)整体丢弃后继续重同步。
|
||||
*/
|
||||
class Ie1000FrameParser {
|
||||
|
||||
private val buffer = ArrayDeque<Byte>()
|
||||
|
||||
/** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
|
||||
fun feed(data: ByteArray): List<Ie1000Reading> {
|
||||
for (b in data) buffer.addLast(b)
|
||||
val readings = mutableListOf<Ie1000Reading>()
|
||||
while (true) {
|
||||
val frame = nextFrame() ?: break
|
||||
parseFrame(frame)?.let(readings::add)
|
||||
}
|
||||
return readings
|
||||
}
|
||||
|
||||
/** 清空内部缓冲(断连或采集会话重置时调用)。 */
|
||||
fun reset() {
|
||||
buffer.clear()
|
||||
}
|
||||
|
||||
/** 从缓冲头部取出一条以 `0D 0A` 结尾的帧(含结束符);不足一条返回 null。 */
|
||||
private fun nextFrame(): List<Byte>? {
|
||||
var index = 0
|
||||
var end = -1
|
||||
while (index + 1 < buffer.size) {
|
||||
if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
|
||||
end = index
|
||||
break
|
||||
}
|
||||
index++
|
||||
}
|
||||
if (end == -1) return null
|
||||
val frame = ArrayList<Byte>(end + 2)
|
||||
repeat(end + 2) { frame.add(buffer.removeFirst()) }
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun parseFrame(frame: List<Byte>): Ie1000Reading? {
|
||||
if (frame.size != FRAME_LENGTH) return null
|
||||
if (frame[0] != HEADER_PRESSURE || frame[5] != HEADER_TEMPERATURE) return null
|
||||
val pressure = Float.fromBits(frame.leIntAt(offset = 1))
|
||||
val temperature = Float.fromBits(frame.leIntAt(offset = 6))
|
||||
return Ie1000Reading(
|
||||
pressure = pressure,
|
||||
temperature = temperature,
|
||||
receivedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 小端序读取 4 字节为 Int(位模式,交由 [Float.fromBits] 解释)。 */
|
||||
private fun List<Byte>.leIntAt(offset: Int): Int =
|
||||
(this[offset].toInt() and 0xFF) or
|
||||
((this[offset + 1].toInt() and 0xFF) shl 8) or
|
||||
((this[offset + 2].toInt() and 0xFF) shl 16) or
|
||||
((this[offset + 3].toInt() and 0xFF) shl 24)
|
||||
|
||||
companion object {
|
||||
const val FRAME_LENGTH = 12
|
||||
|
||||
/** 压力段帧头 'Y'。 */
|
||||
val HEADER_PRESSURE: Byte = 'Y'.code.toByte()
|
||||
|
||||
/** 温度段帧头 'T'。 */
|
||||
val HEADER_TEMPERATURE: Byte = 'T'.code.toByte()
|
||||
|
||||
private const val CR: Byte = 0x0D
|
||||
private const val LF: Byte = 0x0A
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.stec.cmd.protocol.ie1000
|
||||
|
||||
/**
|
||||
* IE-1000 振弦式读数仪单帧采集读数。
|
||||
*
|
||||
* @property pressure 压力值(float 原值,量程单位依设备标定)
|
||||
* @property temperature 温度值(℃)
|
||||
* @property receivedAtMillis 本机接收时刻(epoch 毫秒)
|
||||
*/
|
||||
data class Ie1000Reading(
|
||||
val pressure: Float,
|
||||
val temperature: Float,
|
||||
val receivedAtMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.stec.cmd.protocol.vm208
|
||||
|
||||
/**
|
||||
* VM208 下行命令编码器(写 UUID,协议 6.1)。
|
||||
*
|
||||
* 激励设置命令格式:`0F 0F 06 0A XX XX 0D 0A`,
|
||||
* 其中 `XX XX` 为设置值的大端 16 位表示。
|
||||
* 例:设置 100(0x0064)→ 整条命令 `0F 0F 06 0A 00 64 0D 0A`;
|
||||
* 设备通过 Notify 原样回显该命令作为应答。
|
||||
*/
|
||||
object Vm208CommandEncoder {
|
||||
|
||||
/**
|
||||
* 编码激励设置命令。
|
||||
*
|
||||
* @param excitation 激励设置值,须在 0..65535
|
||||
* @return 8 字节完整命令帧
|
||||
* @throws IllegalArgumentException 超出 16 位范围
|
||||
*/
|
||||
fun encodeExcitation(excitation: Int): ByteArray {
|
||||
require(excitation in 0..0xFFFF) { "激励值超出 16 位范围: $excitation" }
|
||||
return byteArrayOf(
|
||||
0x0F, 0x0F, 0x06, 0x0A,
|
||||
((excitation shr 8) and 0xFF).toByte(),
|
||||
(excitation and 0xFF).toByte(),
|
||||
0x0D, 0x0A,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设备回显的激励命令应答帧。
|
||||
*
|
||||
* @return 应答中的激励值;帧长或帧头不符返回 null
|
||||
*/
|
||||
fun decodeExcitationAck(frame: ByteArray): Int? {
|
||||
if (frame.size != COMMAND_LENGTH) return null
|
||||
if (frame[0] != 0x0F.toByte() || frame[1] != 0x0F.toByte()) return null
|
||||
if (frame[2] != 0x06.toByte() || frame[3] != 0x0A.toByte()) return null
|
||||
if (frame[6] != 0x0D.toByte() || frame[7] != 0x0A.toByte()) return null
|
||||
val high = frame[4].toInt() and 0xFF
|
||||
val low = frame[5].toInt() and 0xFF
|
||||
return (high shl 8) or low
|
||||
}
|
||||
|
||||
const val COMMAND_LENGTH = 8
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.stec.cmd.protocol.vm208
|
||||
|
||||
/**
|
||||
* VM208 Notify 帧解析器(协议原文见 docs/BLE-IE1000.txt)。
|
||||
*
|
||||
* 振弦数据报文格式(ASCII 字段 + 固定结束符):
|
||||
* ```
|
||||
* CC F12345 T334 E14039 SG AA 0x0D 0x0A
|
||||
* ```
|
||||
* - `CC` 报文头、`AA` 报文尾、`0D 0A` 结束符号;
|
||||
* - `F12345`:频率原值 12345 → 12345/10 = 1234.5 Hz;
|
||||
* - `T334`:温度原值 334 → 334/10 = 33.4 ℃;
|
||||
* - `E14039`:电压原值 14039 → 14039/100 = 140 V;
|
||||
* - `SG`:S 后跟 1 位质量字母(G 好 / Q 一般 / N 没元件)。
|
||||
*
|
||||
* 与 [com.stec.cmd.protocol.ie1000.Ie1000FrameParser] 相同的缓冲策略:
|
||||
* [feed] 支持任意分片,按 `0D 0A` 切帧,字段缺失或非数字的脏帧整体丢弃。
|
||||
*/
|
||||
class Vm208FrameParser {
|
||||
|
||||
private val buffer = ArrayDeque<Byte>()
|
||||
|
||||
/** 追加收到的字节,返回本轮累计解析出的完整读数列表。 */
|
||||
fun feed(data: ByteArray): List<Vm208Reading> {
|
||||
for (b in data) buffer.addLast(b)
|
||||
val readings = mutableListOf<Vm208Reading>()
|
||||
while (true) {
|
||||
val frame = nextFrame() ?: break
|
||||
parseFrame(frame)?.let(readings::add)
|
||||
}
|
||||
return readings
|
||||
}
|
||||
|
||||
/** 清空内部缓冲(断连或采集会话重置时调用)。 */
|
||||
fun reset() {
|
||||
buffer.clear()
|
||||
}
|
||||
|
||||
private fun nextFrame(): List<Byte>? {
|
||||
var index = 0
|
||||
var end = -1
|
||||
while (index + 1 < buffer.size) {
|
||||
if (buffer.elementAt(index) == CR && buffer.elementAt(index + 1) == LF) {
|
||||
end = index
|
||||
break
|
||||
}
|
||||
index++
|
||||
}
|
||||
if (end == -1) return null
|
||||
val frame = ArrayList<Byte>(end + 2)
|
||||
repeat(end + 2) { frame.add(buffer.removeFirst()) }
|
||||
return frame
|
||||
}
|
||||
|
||||
private fun parseFrame(frame: List<Byte>): Vm208Reading? {
|
||||
if (frame.size < MIN_FRAME_LENGTH) return null
|
||||
if (frame[0] != HEADER_CC || frame[frame.size - 3] != TAIL_AA) return null
|
||||
val text = frame.toByteArray().toString(Charsets.US_ASCII)
|
||||
val frequency = fieldOf(text, FIELD_FREQUENCY)?.div(SCALE_FREQUENCY) ?: return null
|
||||
val temperature = fieldOf(text, FIELD_TEMPERATURE)?.div(SCALE_TEMPERATURE) ?: return null
|
||||
val voltage = fieldOf(text, FIELD_VOLTAGE)?.div(SCALE_VOLTAGE) ?: return null
|
||||
val qualityCode = qualityCodeOf(text) ?: return null
|
||||
return Vm208Reading(
|
||||
frequency = frequency,
|
||||
temperature = temperature,
|
||||
voltage = voltage,
|
||||
quality = Vm208Quality.fromCode(qualityCode),
|
||||
receivedAtMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 取 `X` 字段后直到下一字母或报文尾的 ASCII 内容并按整数解析;缺失/非法返回 null。 */
|
||||
private fun fieldOf(text: String, field: Char): Double? =
|
||||
fieldRaw(text, field)?.toDoubleOrNull()
|
||||
|
||||
private fun fieldRaw(text: String, field: Char): String? {
|
||||
val start = text.indexOf(field)
|
||||
if (start < 0) return null
|
||||
var end = start + 1
|
||||
while (end < text.length && text[end].isDigit()) end++
|
||||
if (end == start + 1) return null
|
||||
return text.substring(start + 1, end)
|
||||
}
|
||||
|
||||
/** 取 S 质量字段后的 1 个字母(G/Q/N,其余交由 UNKNOWN 兜底);缺字段返回 null。 */
|
||||
private fun qualityCodeOf(text: String): Char? {
|
||||
val start = text.indexOf(FIELD_QUALITY)
|
||||
if (start < 0 || start + 1 >= text.length) return null
|
||||
return text[start + 1]
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** 最短帧:CC F1 T1 E1 Sx AA CR LF = 9 字节。 */
|
||||
const val MIN_FRAME_LENGTH = 9
|
||||
|
||||
/** 报文头 'C''C' 的首字节。 */
|
||||
val HEADER_CC: Byte = 'C'.code.toByte()
|
||||
|
||||
/** 报文尾 'A''A' 的首字节(其后为 0D 0A 结束符)。 */
|
||||
val TAIL_AA: Byte = 'A'.code.toByte()
|
||||
|
||||
private const val FIELD_FREQUENCY = 'F'
|
||||
private const val FIELD_TEMPERATURE = 'T'
|
||||
private const val FIELD_VOLTAGE = 'E'
|
||||
private const val FIELD_QUALITY = 'S'
|
||||
|
||||
private const val SCALE_FREQUENCY = 10.0
|
||||
private const val SCALE_TEMPERATURE = 10.0
|
||||
private const val SCALE_VOLTAGE = 100.0
|
||||
|
||||
private const val CR: Byte = 0x0D
|
||||
private const val LF: Byte = 0x0A
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.stec.cmd.protocol.vm208
|
||||
|
||||
/** VM208 帧内 S 字段携带的元件质量状态。 */
|
||||
enum class Vm208Quality(val code: Char) {
|
||||
/** 元件质量好。 */
|
||||
GOOD('G'),
|
||||
|
||||
/** 元件一般。 */
|
||||
FAIR('Q'),
|
||||
|
||||
/** 没有元件。 */
|
||||
NONE('N'),
|
||||
|
||||
/** 非约定字母的兜底,避免脏帧导致崩溃。 */
|
||||
UNKNOWN('?');
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Char): Vm208Quality =
|
||||
entries.firstOrNull { it.code == code } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VM208 综合采集仪单帧采集读数。
|
||||
*
|
||||
* @property frequency 频率(Hz,帧内原值 / 10)
|
||||
* @property temperature 温度(℃,帧内原值 / 10)
|
||||
* @property voltage 电压(V,帧内原值 / 100)
|
||||
* @property quality 元件质量状态
|
||||
* @property receivedAtMillis 本机接收时刻(epoch 毫秒)
|
||||
*/
|
||||
data class Vm208Reading(
|
||||
val frequency: Double,
|
||||
val temperature: Double,
|
||||
val voltage: Double,
|
||||
val quality: Vm208Quality,
|
||||
val receivedAtMillis: Long,
|
||||
)
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.stec.cmd.protocol.ie1000
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** 用协议文档示例字节做冒烟验证。 */
|
||||
class Ie1000FrameParserTest {
|
||||
|
||||
private val parser = Ie1000FrameParser()
|
||||
|
||||
/**
|
||||
* 协议示例帧:59 D8 BE 50 41 54 00 00 E8 41 0D 0A。
|
||||
* 压力段小端 float 0x4150BED8 = 13.0465927…(文档标注 13.04 为截断写法);
|
||||
* 温度段 0x41E80000 = 29.0 精确。
|
||||
*/
|
||||
private val sample = byteArrayOf(
|
||||
0x59.toByte(), 0xD8.toByte(), 0xBE.toByte(), 0x50.toByte(), 0x41.toByte(),
|
||||
0x54.toByte(), 0x00, 0x00, 0xE8.toByte(), 0x41.toByte(),
|
||||
0x0D, 0x0A,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `整帧一次喂入解析出协议示例读数`() {
|
||||
val readings = parser.feed(sample)
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(13.0466f, readings[0].pressure, 0.001f)
|
||||
assertEquals(29.0f, readings[0].temperature, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `逐字节分片喂入仍可切帧(残包容错)`() {
|
||||
val readings = sample.flatMap { listOf(it) }.map { parser.feed(byteArrayOf(it)) }
|
||||
.reduce { acc, list -> acc + list }
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(13.0466f, readings[0].pressure, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `粘包两帧一次解析`() {
|
||||
val doubled = sample + sample
|
||||
|
||||
val readings = parser.feed(doubled)
|
||||
|
||||
assertEquals(2, readings.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `脏帧被丢弃后继续重同步`() {
|
||||
val dirty = byteArrayOf(0x01, 0x02, 0x0D, 0x0A) // 长度不足的垃圾帧
|
||||
val readings = parser.feed(dirty + sample)
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(13.0466f, readings[0].pressure, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `帧头错误的完整帧被丢弃`() {
|
||||
val badHeader = sample.copyOf().also { it[0] = 0x58 } // 'X' 而非 'Y'
|
||||
|
||||
val readings = parser.feed(badHeader)
|
||||
|
||||
assertTrue(readings.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.stec.cmd.protocol.vm208
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** 用协议文档示例帧做冒烟验证。 */
|
||||
class Vm208FrameParserTest {
|
||||
|
||||
private val parser = Vm208FrameParser()
|
||||
|
||||
/**
|
||||
* 协议示例报文:CC F12345 T334 E14039 SG AA 0D 0A
|
||||
* → 频率 1234.5 Hz、温度 33.4 ℃、电压 140 V、质量 GOOD。
|
||||
*/
|
||||
private fun sampleFrame(): ByteArray =
|
||||
"CC F12345 T334 E14039 SG AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
|
||||
@Test
|
||||
fun `整帧解析出协议示例读数`() {
|
||||
val readings = parser.feed(sampleFrame())
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(1234.5, readings[0].frequency, 1e-9)
|
||||
assertEquals(33.4, readings[0].temperature, 1e-9)
|
||||
assertEquals(140.0, readings[0].voltage, 1e-9)
|
||||
assertEquals(Vm208Quality.GOOD, readings[0].quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `分片喂入仍可切帧`() {
|
||||
val frame = sampleFrame()
|
||||
val mid = frame.size / 2
|
||||
val readings = parser.feed(frame.copyOfRange(0, mid)) + parser.feed(frame.copyOfRange(mid, frame.size))
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(Vm208Quality.GOOD, readings[0].quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `状态字母映射 Q 与 N`() {
|
||||
val qFrame = "CC F12345 T334 E14039 SQ AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
val nFrame = "CC F12345 T334 E14039 SN AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
|
||||
val readings = parser.feed(qFrame + nFrame)
|
||||
|
||||
assertEquals(2, readings.size)
|
||||
assertEquals(Vm208Quality.FAIR, readings[0].quality)
|
||||
assertEquals(Vm208Quality.NONE, readings[1].quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `未知状态字母映射 UNKNOWN 而非崩溃`() {
|
||||
val xFrame = "CC F12345 T334 E14039 SX AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
|
||||
val readings = parser.feed(xFrame)
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
assertEquals(Vm208Quality.UNKNOWN, readings[0].quality)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `缺字段脏帧被丢弃`() {
|
||||
val dirty = "CC F12345 AA".toByteArray(Charsets.US_ASCII) + byteArrayOf(0x0D, 0x0A)
|
||||
|
||||
val readings = parser.feed(dirty + sampleFrame())
|
||||
|
||||
assertEquals(1, readings.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `激励命令编码与应答回解析`() {
|
||||
val command = Vm208CommandEncoder.encodeExcitation(100)
|
||||
|
||||
assertEquals(8, command.size)
|
||||
assertEquals("0F0F060A00640D0A", command.joinToString("") { "%02X".format(it) })
|
||||
assertEquals(100, Vm208CommandEncoder.decodeExcitationAck(command))
|
||||
assertNull(Vm208CommandEncoder.decodeExcitationAck(command.copyOf(7)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `激励值越界抛出`() {
|
||||
try {
|
||||
Vm208CommandEncoder.encodeExcitation(0x10000)
|
||||
throw AssertionError("应抛出 IllegalArgumentException")
|
||||
} catch (expected: IllegalArgumentException) {
|
||||
// 预期路径
|
||||
}
|
||||
}
|
||||
}
|
||||
10
build.gradle.kts
Normal file
10
build.gradle.kts
Normal file
@@ -0,0 +1,10 @@
|
||||
// 根构建脚本:只声明插件版本(apply false),各模块按需 apply
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
}
|
||||
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
|
||||
}
|
||||
138
docs/安卓客户端主框架架构说明.md
Normal file
138
docs/安卓客户端主框架架构说明.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 安卓客户端主框架架构说明
|
||||
|
||||
> S0 工程骨架交付(2026-09-03)。本文档说明主框架分层、模块职责、关键约定与业务挂载方法,是 S1–S4 功能开发的挂载依据。
|
||||
|
||||
## 1. 架构总览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
APP[":app 业务壳<br/>导航 · 主题 · 权限 · 配置注入<br/>M1–M6 页面挂载点"]
|
||||
subgraph CORE["core 基础层"]
|
||||
NET[":core:network<br/>鉴权三要素 · 统一解析"]
|
||||
DB[":core:database<br/>Room · 上传队列"]
|
||||
BLE[":core:ble<br/>扫描 · GATT 封装"]
|
||||
COMMON[":core:common<br/>AppResult · UiState · 日志"]
|
||||
end
|
||||
PROTO[":ble-protocol<br/>IE-1000 / VM208 帧解析<br/>纯 Kotlin JVM · 零依赖"]
|
||||
|
||||
APP --> NET
|
||||
APP --> DB
|
||||
APP --> BLE
|
||||
APP --> PROTO
|
||||
NET --> COMMON
|
||||
DB --> COMMON
|
||||
BLE --> COMMON
|
||||
BLE -. "Notify 字节流" .-> PROTO
|
||||
```
|
||||
|
||||
**依赖规则**(静态校验脚本强制核查):
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| app → core:* + ble-protocol | 业务壳是唯一允许依赖全部模块的层 |
|
||||
| core:{network,database,ble} → core:common | core 模块横向互不依赖 |
|
||||
| ble-protocol 零依赖 | 纯 Kotlin JVM,无 Android / 第三方框架依赖 |
|
||||
| 业务代码禁止绕过 core 层 | S1+ feature 只允许经 core 能力访问网络/存储/蓝牙 |
|
||||
|
||||
## 2. 模块职责与关键类
|
||||
|
||||
| 模块 | 职责 | 关键类 |
|
||||
|---|---|---|
|
||||
| `:app` | 单 Activity 导航壳、5 Tab、权限、配置注入 | `SteCmdApplication` `MainActivity` `FeatureMount` `config/ConfigRepository` |
|
||||
| `:core:common` | 统一结果/视图状态/日志门面 | `AppResult` `UiState` `AppLog` |
|
||||
| `:core:network` | 平台 HTTP 框架:鉴权三要素 + 返回码统一解析 | `NetworkConfig` `TokenProvider` `AuthInterceptor` `ApiEnvelope` `ApiError` `NetworkModule` |
|
||||
| `:core:database` | Room 离线优先存储 + 上传队列参考实现 | `AppDatabase` `UploadQueueEntity` `UploadQueueDao` `DatabaseModule` |
|
||||
| `:core:ble` | BLE 扫描(名称前缀过滤)与 GATT 连接骨架 | `BleScanner` `BleConnector` `BleSession` `BleUuids` `DeviceType` |
|
||||
| `:ble-protocol` | 两类设备协议帧解析/编码(独立单测) | `Ie1000FrameParser` `Vm208FrameParser` `Vm208CommandEncoder` `Ie1000Reading` `Vm208Reading` |
|
||||
|
||||
## 3. 关键约定
|
||||
|
||||
### 3.1 鉴权三要素流(接口文档 1.2)
|
||||
|
||||
```
|
||||
ConfigRepository(DataStore+内存快照, app 壳实现 NetworkConfig/TokenProvider)
|
||||
└→ AuthInterceptor(core:network): 请求头注入 SecretKey / SystemCode / Token
|
||||
· Token 为空跳过(登录/验证码接口天然不携带)
|
||||
· 密钥值不进代码库(M7-04),运行期经 S1 配置页写入 DataStore
|
||||
```
|
||||
|
||||
Hilt 绑定:`config/AppModule.kt` 中 `@Binds` 将 `ConfigRepository` 绑定为 `NetworkConfig` 与 `TokenProvider`,补全 `NetworkModule` 依赖图。
|
||||
|
||||
### 3.2 返回码统一解析(接口文档 1.1)
|
||||
|
||||
| code | 语义 | 异常类型 |
|
||||
|---|---|---|
|
||||
| "200" | 成功 | —(`ApiEnvelope.bodyOrThrow()` 返回 data) |
|
||||
| "500" | 业务失败 | `ApiError.ServerError` |
|
||||
| "429" | 一分钟内同条数据频繁请求超限 | `ApiError.TooManyRequests` |
|
||||
| "403" | 同条数据连续报错超限(平台锁定) | `ApiError.Locked` |
|
||||
| 传输层失败 | 超时/断网 | `ApiError.NetworkError` |
|
||||
|
||||
code 为**字符串**类型;S1+ 业务层只捕获 `ApiError` 并按子类提示。
|
||||
|
||||
### 3.3 协议解析流(BLE Notify → 上传队列)
|
||||
|
||||
```
|
||||
BleSession.notifyBytes(ByteArray 流)
|
||||
└→ Ie1000FrameParser / Vm208FrameParser.feed(bytes) ← 内部缓冲,天然处理粘包/残包
|
||||
└→ Ie1000Reading / Vm208Reading
|
||||
└→ 组装上传 JSON → UploadQueueDao.enqueue() ← 离线优先,网络可用后补传
|
||||
```
|
||||
|
||||
- IE-1000:`'Y' + 4B float(压力,小端) + 'T' + 4B float(温度) + 0D0A`,12 字节定长;
|
||||
- VM208:`CC F原值 T原值 E原值 S质量 AA + 0D0A`,缩放 /10 /10 /100;
|
||||
- VM208 激励命令:`0F 0F 06 0A XX XX 0D 0A`(大端 16 位),`Vm208CommandEncoder`。
|
||||
- ⚠ VM208 服务 UUID 原文档仅 31 位十六进制,`BleUuids` 按末位补 0 占位,**S2 真机联调时用 nRF Connect 校准**。
|
||||
|
||||
### 3.4 网络安全白名单
|
||||
|
||||
`app/src/main/res/xml/network_security_config.xml`:默认全域禁明文;仅接口文档 1.2.4 测试 IP `106.15.183.20` 开放 cleartext(联调用,发布前如生产全 HTTPS 可移除该 domain-config)。
|
||||
|
||||
## 4. 业务挂载指南(S1 挂载步骤)
|
||||
|
||||
挂载机制:`FeatureMount.mounts` 注册表声明每个 Tab 的 Fragment 类;导航壳只认注册表,业务页替换注册项即可。
|
||||
|
||||
以 S1 首页(M2 项目列表)为例:
|
||||
|
||||
1. **建 Fragment**:`app/src/main/kotlin/com/stec/cmd/feature/home/HomeFragment.kt`,`@AndroidEntryPoint` + ViewBinding(或迁移至团队选定的 UI 方案);
|
||||
2. **建 ViewModel**:`HomeViewModel`,注入 S1 新建的 Retrofit Service(声明在 core:network 或独立 feature 模块);
|
||||
3. **换注册项**:`FeatureMount.mounts` 中 `Tab.HOME` 的 `fragmentClass` 从 `HomeFragment::class.java`(占位)改为业务 Fragment;
|
||||
4. **加接口**:Retrofit Service 方法返回 `ApiEnvelope<T>`,调用侧 `bodyOrThrow()` 拿业务数据、捕获 `ApiError`;
|
||||
5. **跑校验**:`./gradlew :app:assembleDebug`,并确认静态校验脚本(见 §6)通过(menu↔FeatureMount id 对应关系不受影响)。
|
||||
|
||||
M1(我的)额外步骤:配置页 UI 读写 `ConfigRepository.updateConnection()/updateToken()`(M1-06)。
|
||||
|
||||
## 5. 目录结构对照
|
||||
|
||||
```
|
||||
SteCmdAndroid/
|
||||
├─ app/ # 业务壳(本表 §2 :app)
|
||||
├─ core/
|
||||
│ ├─ common/ # AppResult / UiState / AppLog
|
||||
│ ├─ network/ # AuthInterceptor / ApiEnvelope / ApiError / NetworkModule
|
||||
│ ├─ database/ # AppDatabase / UploadQueue 实体与 DAO / DatabaseModule
|
||||
│ └─ ble/ # BleScanner / BleConnector / BleSession / BleUuids / DeviceType
|
||||
├─ ble-protocol/ # 协议解析(src/main + src/test 冒烟单测)
|
||||
├─ gradle/
|
||||
│ └─ libs.versions.toml # 版本目录:全工程唯一版本事实源
|
||||
├─ docs/ # 规划文档、协议原文、本说明
|
||||
├─ settings.gradle.kts # 6 模块注册
|
||||
└─ build.gradle.kts # 根:插件版本声明(apply false)
|
||||
```
|
||||
|
||||
## 6. 环境与验证
|
||||
|
||||
| 场景 | 命令 / 操作 |
|
||||
|---|---|
|
||||
| 首次打开 | Android Studio(JDK 17)打开工程根目录,等待 Gradle Sync |
|
||||
| 编译 | `./gradlew :app:assembleDebug` |
|
||||
| 协议单测 | `./gradlew :ble-protocol:test` |
|
||||
| 静态校验(本机已通过 198 项) | `python3 /tmp/verify_core.py && python3 /tmp/verify_app.py`(脚本随会话生成) |
|
||||
| 真机联调 | 配置页写入三要素 + 测试地址后,先验证登录(M1-01) |
|
||||
|
||||
## 7. 已知遗留(S1 前处理)
|
||||
|
||||
1. Gradle Sync / 编译 / 单测**尚未在真实环境执行**(交付环境无 JDK/Android SDK);
|
||||
2. VM208 服务 UUID 需真机校准(§3.3);
|
||||
3. splash 与导航图标为几何占位,S6 发布前替换正式视觉;
|
||||
4. 密钥(SecretKey/SystemCode)首次运行需经配置页写入;协议文档附带密钥文件仅用于联调,不得入库。
|
||||
133
docs/安卓客户端功能明细表.md
Normal file
133
docs/安卓客户端功能明细表.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# 安卓客户端功能明细表
|
||||
|
||||
> 2026-09-03 重建版:原文档随工作树清理丢失,按任务记录与接口解析稿(V4.5)复刻。接口清单以解析稿为准(15 个在用 + 2 个废除子接口)。
|
||||
|
||||
## 文档属性
|
||||
|
||||
| 属性 | 值 |
|
||||
|---|---|
|
||||
| 项目 | 上海城建勘测院 · 监测数据不落地平台安卓客户端 |
|
||||
| 依据 | 接口管理规范 V4.5、BLE-IE1000/VM208 蓝牙协议说明 |
|
||||
| 优先级定义 | P0=应用可发布的最小闭环;P1=重要;P2=增强 |
|
||||
| 功能点合计 | 36(P0×23 / P1×11 / P2×2) |
|
||||
|
||||
## 模块总览
|
||||
|
||||
| 模块 | 名称 | 功能点数 | P0 | P1 | P2 |
|
||||
|---|---|---|---|---|---|
|
||||
| M1 | 我的(登录与配置) | 7 | 5 | 1 | 1 |
|
||||
| M2 | 首页(项目工作台) | 3 | 2 | 1 | 0 |
|
||||
| M3 | 任务管理 | 5 | 3 | 2 | 0 |
|
||||
| M4 | 采集·蓝牙 | 9 | 7 | 2 | 0 |
|
||||
| M5 | 采集·测点数据 | 4 | 0 | 3 | 1 |
|
||||
| M6 | 统计与数据上传 | 4 | 2 | 2 | 0 |
|
||||
| M7 | 基础工程层 | 4 | 4 | 0 | 0 |
|
||||
| 合计 | | 36 | 23 | 11 | 2 |
|
||||
|
||||
## M1 我的(登录与配置)
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M1-01 | 账号密码登录 | 2.2.1 | P0 | 请求头带 SecretKey/SystemCode,成功取得 Token |
|
||||
| M1-02 | 获取短信验证码 | 2.2.2 | P0 | 手机号 +86 需转 %2B86 |
|
||||
| M1-03 | 验证码登录 | 2.2.3 | P0 | 国内手机号全匹配校验 |
|
||||
| M1-04 | 修改登录密码 | 2.3.1 | P1 | 登录态下操作 |
|
||||
| M1-05 | 当前用户信息展示 | 2.4 | P0 | 头像/姓名/角色等 |
|
||||
| M1-06 | 服务器与密钥配置管理 | 无(本地) | P0 | baseUrl/SecretKey/SystemCode/Token 持久化(DataStore+加密),M7-04 落地入口 |
|
||||
| M1-07 | 退出登录与 Token 清理 | 无(本地) | P2 | 清除本地 Token 与缓存 |
|
||||
|
||||
## M2 首页(项目工作台)
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M2-01 | 当前用户项目列表 | 2.5.1.1 | P0 | 外业人员可参与多项目 |
|
||||
| M2-02 | 项目基本信息展示 | 2.5.1.1(data) | P0 | 项目名称/编号/状态 |
|
||||
| M2-03 | 项目切换 | 2.5.1.1(本地选中态) | P1 | 切换后任务/统计跟随刷新 |
|
||||
|
||||
## M3 任务管理
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M3-01 | APP 端任务列表 | 2.6 | P0 | 外业人员当日/近期任务 |
|
||||
| M3-02 | 项目监测计划任务列表 | 2.7 | P0 | 按监测计划组织 |
|
||||
| M3-03 | 任务详情 | 2.6/2.7(data) | P0 | 测组、测点、计划周期等 |
|
||||
| M3-04 | 任务状态流转展示 | 2.6/2.7(data) | P1 | 未开始/进行中/已完成 |
|
||||
| M3-05 | 任务筛选与下拉刷新 | 2.6/2.7 | P1 | 按状态/时间过滤 |
|
||||
|
||||
## M4 采集·蓝牙
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M4-01 | 蓝牙与定位权限申请 | 无(系统) | P0 | Android 12L 分版(BLUETOOTH_SCAN/CONNECT vs 旧版+定位) |
|
||||
| M4-02 | 设备扫描 | 无(BLE) | P0 | 按名称前缀过滤:IE-1000、VM208 |
|
||||
| M4-03 | 设备连接管理 | 无(BLE GATT) | P0 | 两设备各有服务/写/Notify UUID;连接状态流 |
|
||||
| M4-04 | IE-1000 数据接收解析 | 无(BLE Notify) | P0 | 帧 `'Y'+4B float+'T'+4B float+0D 0A`(压力/温度) |
|
||||
| M4-05 | VM208 数据接收解析 | 无(BLE Notify) | P0 | 帧 `CC F%05d T%03d E%05d S%c AA+0D 0A`;频率/10Hz、温度/10℃、电压/100V |
|
||||
| M4-06 | VM208 激励命令下发 | 无(BLE Write) | P0 | `0F 0F 06 0A XX XX 0D 0A`,XX XX 大端 16 位 |
|
||||
| M4-07 | 采集数据本地暂存 | 无(Room) | P0 | 先落本地队列,网络可用后上传(离线优先) |
|
||||
| M4-08 | 采集会话管理 | 无(本地) | P1 | 开始/停止采集、绑定任务与测点 |
|
||||
| M4-09 | 断连与异常处理 | 无(BLE) | P1 | 断连提示、自动重连策略 |
|
||||
|
||||
## M5 采集·测点数据
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M5-01 | 测组监测点列表 | 2.9 | P1 | 按测组拉取监测点 |
|
||||
| M5-02 | 基准点获取 | 2.14 | P1 | 水平位移等计算的基准 |
|
||||
| M5-03 | 工点数据 | 2.15 | P1 | 工点/测项结构(目录页 2.15 笔误 ControlPoint,以正文为准) |
|
||||
| M5-04 | 手动录入/补录 | 2.13(配合) | P2 | 无法蓝牙采集时人工录入后走同一上传队列 |
|
||||
|
||||
## M6 统计与数据上传
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M6-01 | 原始文件上传与解析 | 2.13 | P0 | 数据解析(原始文件上传),采集文件的兜底通道 |
|
||||
| M6-02 | 项目监测计划统计表 | 2.10 | P0 | 项目维度完成度 |
|
||||
| M6-03 | 测组统计表 | 2.11 | P1 | 指定监测计划的测组统计 |
|
||||
| M6-04 | 监测点统计表 | 2.12 | P1 | 测点维度历史数据 |
|
||||
|
||||
## M7 基础工程层
|
||||
|
||||
| 编号 | 功能点 | 对应 API | 优先级 | 说明 |
|
||||
|---|---|---|---|---|
|
||||
| M7-01 | 网络框架 | 全部 | P0 | 鉴权三要素拦截器 + 200/500/429/403 统一解析(core:network) |
|
||||
| M7-02 | 本地存储框架 | 无 | P0 | Room 队列 + 加密配置存储(core:database + DataStore) |
|
||||
| M7-03 | 协议解析独立模块 | 无 | P0 | 纯 Kotlin JVM,脱离真机可单测(ble-protocol) |
|
||||
| M7-04 | 密钥配置管理 | 无 | P0 | SecretKey/SystemCode 不进代码库,可随时更换(.gitignore + ConfigRepository) |
|
||||
|
||||
## 第 3 节 待确认问题(8 项)
|
||||
|
||||
| # | 问题 | 缺省处理 |
|
||||
|---|---|---|
|
||||
| 1 | SecretKey/SystemCode 的分发与更换流程 | 做成配置项,支持不换包更换 |
|
||||
| 2 | Token 有效期与刷新机制 | 无刷新接口,过期按 401/500 重新登录 |
|
||||
| 3 | 429/403 的解锁规则与窗口 | 按接口文档语义提示用户稍后重试 |
|
||||
| 4 | 任务状态是否允许 APP 端上报变更 | S0 只读展示,不上报 |
|
||||
| 5 | 2.13 原始文件上传的文件格式与大小限制 | 按采集仪原始文件直传 |
|
||||
| 6 | 监测值上传是否走 2.8(已废除)替代接口 | 以 2.13 + 待定接口为准,待院方确认 |
|
||||
| 7 | 蓝牙后台采集是否需要 | 默认前台采集 |
|
||||
| 8 | 多项目并行采集的合并口径 | 单项目单会话 |
|
||||
|
||||
## 附录 A 接口覆盖对照
|
||||
|
||||
| 章节 | 接口名称 | 状态 | 承载模块 |
|
||||
|---|---|---|---|
|
||||
| 2.2.1 | 账号密码登录 | 在用 | M1-01 |
|
||||
| 2.2.2 | 发送验证码 | 在用 | M1-02 |
|
||||
| 2.2.3 | 手机验证码登录 | 在用 | M1-03 |
|
||||
| 2.3.1 | 修改登录密码 | 在用 | M1-04 |
|
||||
| 2.4 | 获取当前用户信息 | 在用 | M1-05 |
|
||||
| 2.5.1.1 | 获取当前用户项目列表 | 在用 | M2-01/02/03 |
|
||||
| 2.6 | 获取 APP 端任务列表 | 在用 | M3-01/03/04/05 |
|
||||
| 2.7 | 获取项目监测计划任务列表 | 在用 | M3-02/03/04/05 |
|
||||
| 2.8.1 | 获取监测计划 | 废除 | 不实现 |
|
||||
| 2.8.2 | 上传本次监测值 | 废除 | 不实现(上传通道待院方确认,见待确认 #6) |
|
||||
| 2.9 | 获取测组包含的监测点列表 | 在用 | M5-01 |
|
||||
| 2.10 | 获取项目监测计划统计表 | 在用 | M6-02 |
|
||||
| 2.11 | 获取指定监测计划的测组统计表 | 在用 | M6-03 |
|
||||
| 2.12 | 监测点统计表 | 在用 | M6-04 |
|
||||
| 2.13 | 数据解析(原始文件上传) | 在用 | M6-01、M5-04 |
|
||||
| 2.14 | 获取基准点 | 在用 | M5-02 |
|
||||
| 2.15 | 获取工点数据 | 在用 | M5-03 |
|
||||
|
||||
> 返回状态值约定(接口文档 1.1):200 成功;500 失败;429 同一条数据一分钟内频繁请求超限;403 同一条数据连续报错超限。
|
||||
67
docs/安卓客户端开发计划表.md
Normal file
67
docs/安卓客户端开发计划表.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 安卓客户端开发计划表
|
||||
|
||||
> 2026-09-03 重建版:原文档随工作树清理丢失,按任务记录复刻。工作量与阶段划分保持原口径。
|
||||
|
||||
## 技术基线
|
||||
|
||||
| 项 | 选型 |
|
||||
|---|---|
|
||||
| 语言 | Kotlin 2.0.21 |
|
||||
| 架构 | MVVM + Hilt 2.52 + Coroutines 1.9.0 |
|
||||
| 网络 | Retrofit 2.11.0 + OkHttp 4.12.0 + kotlinx.serialization 1.7.3 |
|
||||
| 本地存储 | Room 2.6.1(离线优先)+ DataStore 1.1.1 + EncryptedSharedPreferences |
|
||||
| 蓝牙 | Android 原生 BLE;协议解析器独立为纯 Kotlin JVM 模块(ble-protocol) |
|
||||
| 构建 | AGP 8.7.3 / Gradle 8.10.2 / JDK 17 / minSdk 26 / targetSdk 34 |
|
||||
| 测试环境 | https://jcd.stec.p-q.co(白名单,见 network_security_config) |
|
||||
|
||||
## 阶段与迭代计划(6 阶段,12 周)
|
||||
|
||||
| 阶段 | 内容 | 工作量 | 周期 | 依赖 |
|
||||
|---|---|---|---|---|
|
||||
| S0 工程骨架 | Gradle 多模块、core 基础层、ble-protocol、app 壳(导航/主题/权限/配置) | 4 人日 | 0.5 周 | — |
|
||||
| S1 登录与首页 | M1 全部 + M2 + M7 配置注入 + 修改密码 | 22 人日 | 2 周 | S0 |
|
||||
| S2 任务与采集框架 | M3 全部 + M4 骨架(权限/扫描/连接) | 24 人日 | 2 周 | S1 |
|
||||
| S3 蓝牙采集深化 | M4 解析/下发/暂存/会话 + M5 测点数据 | 28 人日 | 3 周 | S2 |
|
||||
| S4 统计与上传 | M6 全部 + 离线队列上传调度 | 12 人日 | 2 周 | S3 |
|
||||
| S5 联调与测试 | 接口联调、真机回归、性能与弱网测试 | 20 人日 | 2 周 | S4 |
|
||||
| S6 发布准备 | 签名、混淆、应用商店/院内分发、文档 | 4 人日 | 0.5 周 | S5 |
|
||||
|
||||
- 迭代工作量合计:4+22+24+28+12+4 = **94 人日**(其中开发 86 + S0/S6 管理 8)
|
||||
- 测试 20 人日(S5)+ 管理发布 8 人日 → **总计约 114 人日**
|
||||
|
||||
## 模块 × 工作量矩阵(开发 86 人日)
|
||||
|
||||
| 模块 | M1 我的 | M2 首页 | M3 任务 | M4 采集·蓝牙 | M5 测点 | M6 统计 | M7 基础层 | 合计 |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| 人日 | 12 | 6 | 10 | 28 | 12 | 8 | 10 | 86 |
|
||||
|
||||
## 里程碑(MS1–MS6)
|
||||
|
||||
| 里程碑 | 时点 | 验收标准 |
|
||||
|---|---|---|
|
||||
| MS1 工程可构建 | S0 末(第 0.5 周) | Android Studio 打开即 Sync,:app:assembleDebug 通过,协议单测绿 |
|
||||
| MS2 登录闭环 | S1 末(第 2.5 周) | 三种登录方式 + Token 管理 + 项目列表可用 |
|
||||
| MS3 任务可用 | S2 末(第 4.5 周) | 任务列表/详情完整,BLE 扫描连接真机通过 |
|
||||
| MS4 采集闭环 | S3 末(第 7.5 周) | 真机采集→解析→本地暂存全链路跑通 |
|
||||
| MS5 数据闭环 | S4 末(第 9.5 周) | 采集数据上传成功,弱网队列补传可用 |
|
||||
| MS6 发布 | S6 末(第 12 周) | 签名包通过验收测试,交付文档齐全 |
|
||||
|
||||
## 风险与应对(8 项)
|
||||
|
||||
| # | 风险 | 应对 |
|
||||
|---|---|---|
|
||||
| 1 | SecretKey/SystemCode 更换频繁 | 全配置化,支持不换包更换(M7-04) |
|
||||
| 2 | 429/403 限流误伤外业作业 | 队列退避重试 + 明确用户提示 |
|
||||
| 3 | BLE 各厂商兼容性差异 | 主流机型真机矩阵测试;协议层独立单测降低解析回归成本 |
|
||||
| 4 | 弱网/断网场景数据丢失 | Room 离线队列先落盘,网络恢复自动补传 |
|
||||
| 5 | 接口变更(V4.5 之后) | kotlinx.serialization 显式注解,字段演进留默认值 |
|
||||
| 6 | 2.8 废除后的上传通道未定 | 预留 UploadQueue 抽象,通道可替换 |
|
||||
| 7 | 真机依赖与外业时间窗 | S0–S2 期间提前申请设备与测试账号 |
|
||||
| 8 | 人员并行度 | M4(28 人日)为关键路径,S3 阶段优先投入 |
|
||||
|
||||
## 交付物清单
|
||||
|
||||
- 可发布 APK(签名、混淆配置齐备)
|
||||
- 源码工程(本仓库)+ 架构说明文档
|
||||
- 测试报告(S5 产出)
|
||||
- 《安卓客户端功能明细表》(接口覆盖对照)
|
||||
12
gradle.properties
Normal file
12
gradle.properties
Normal file
@@ -0,0 +1,12 @@
|
||||
# Gradle 守护进程 JVM 参数
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# 官方代码风格,避免平台差异
|
||||
org.gradle.kotlin.dsl.skipMetadataVersionCheck=false
|
||||
|
||||
# AndroidX
|
||||
android.useAndroidX=true
|
||||
# 每个模块独立 R 类,加速构建
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# Kotlin 代码风格
|
||||
kotlin.code.style=official
|
||||
99
gradle/libs.versions.toml
Normal file
99
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,99 @@
|
||||
# 版本目录(Version Catalog):全工程唯一版本事实源
|
||||
# 选型原则:全部采用相互兼容的已知稳定版(AGP 8.7.3 需 Gradle ≥ 8.9,wrapper 为 8.10.2)
|
||||
|
||||
[versions]
|
||||
# 构建链
|
||||
agp = "8.7.3"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.28"
|
||||
|
||||
# 依赖注入
|
||||
hilt = "2.52"
|
||||
|
||||
# 持久化
|
||||
room = "2.6.1"
|
||||
datastore = "1.1.1"
|
||||
securityCrypto = "1.1.0-alpha06"
|
||||
|
||||
# 网络
|
||||
retrofit = "2.11.0"
|
||||
okhttp = "4.12.0"
|
||||
kotlinxSerialization = "1.7.3"
|
||||
|
||||
# 协程与生命周期
|
||||
coroutines = "1.9.0"
|
||||
lifecycle = "2.8.7"
|
||||
|
||||
# AndroidX UI
|
||||
coreKtx = "1.13.1"
|
||||
appcompat = "1.7.0"
|
||||
activity = "1.9.3"
|
||||
fragment = "1.8.5"
|
||||
navigation = "2.8.4"
|
||||
material = "1.12.0"
|
||||
splashscreen = "1.0.1"
|
||||
|
||||
# 测试
|
||||
junit = "4.13.2"
|
||||
androidxJunit = "1.2.1"
|
||||
espresso = "3.6.1"
|
||||
|
||||
[libraries]
|
||||
# AndroidX 基础
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||
androidx-activity = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
|
||||
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
|
||||
androidx-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashscreen" }
|
||||
|
||||
# 生命周期
|
||||
androidx-lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
|
||||
|
||||
# 导航
|
||||
androidx-navigation-fragment = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
|
||||
androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
|
||||
|
||||
# Room
|
||||
androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||
androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||
|
||||
# DataStore + 加密存储
|
||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||
|
||||
# Material
|
||||
google-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||
|
||||
# Hilt
|
||||
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }
|
||||
|
||||
# 网络
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-kotlinx = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||
|
||||
# 序列化
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||
|
||||
# 协程
|
||||
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
|
||||
# 测试
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" }
|
||||
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
252
gradlew
vendored
Executable file
252
gradlew
vendored
Executable file
@@ -0,0 +1,252 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
gradlew.bat
vendored
Normal file
94
gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
32
settings.gradle.kts
Normal file
32
settings.gradle.kts
Normal file
@@ -0,0 +1,32 @@
|
||||
// 监测数据不落地平台 · 安卓客户端 —— 工程模块注册与仓库配置
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google {
|
||||
content {
|
||||
includeGroupByRegex("com\\.android.*")
|
||||
includeGroupByRegex("com\\.google.*")
|
||||
includeGroupByRegex("androidx.*")
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
// 依赖仓库统一在此声明,模块级 build 文件禁止再配 repositories
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "SteCmdAndroid"
|
||||
|
||||
include(":app")
|
||||
include(":core:common")
|
||||
include(":core:network")
|
||||
include(":core:database")
|
||||
include(":core:ble")
|
||||
include(":ble-protocol")
|
||||
Reference in New Issue
Block a user