This commit is contained in:
阿猫
2026-09-03 12:38:16 +08:00
parent 09669fe4a2
commit e96e92d7ab
68 changed files with 2810 additions and 0 deletions

28
.gitignore vendored Normal file
View 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-04SecretKey/SystemCode/keystore 一律不入库)
*.jks
*.keystore
secrets.properties
keystore.properties
# 日志与临时文件
*.log
.DS_Store

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# 监测数据不落地平台 · 安卓客户端
外业监测数据「不落地」采集与上传工具现场用手机通过蓝牙连接振弦式读数仪IE-1000与综合采集仪VM208解析采集数据并直接上传至院方监测平台配合离线队列保证弱网/断网可用。
> 本文件为 2026-09-03 重建版(基线文件曾随工作树清理丢失,按任务记录复刻)。
## 技术基线
| 项 | 选型 |
|---|---|
| 语言 / UI | Kotlin 2.0.21、View 体系 + Material 3 |
| 架构 | MVVMViewModel + 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 业务壳导航、主题、权限、配置注入M1M6 页面挂载点)
core
├ common AppResult / UiState / 日志门面(纯 Kotlin 优先)
├ network 鉴权三要素拦截器、ApiEnvelope 统一解析、Retrofit 装配
├ database Room AppDatabase + 上传队列(离线优先参考实现)
└ ble BLE 扫描按名称前缀过滤、GATT 连接封装骨架
ble-protocol 纯 Kotlin JVMIE-1000 / VM208 帧解析(零 Android 依赖,可独立单测)
```
依赖方向:`app → core:* + ble-protocol``core:{network,database,ble} → core:common``ble-protocol` 零依赖。
## 快速开始
1. Android StudioLadybug+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 | 登录后颁发,登录接口外必带 | TokenProviderDataStore |
## 文档索引
- `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
View File

@@ -0,0 +1,74 @@
// app 业务壳导航、主题、权限、配置注入M1M6 页面经 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
View 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

View 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>

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 采集占位M4+M5 数据采集S2/S3 挂载扫描与采集页)。 */
@AndroidEntryPoint
class CollectFragment : PlaceholderFragment()

View 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 {
/** 底部导航 TabitemId 与 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 }
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 首页占位M2 项目工作台S1 挂载项目列表)。 */
@AndroidEntryPoint
class HomeFragment : PlaceholderFragment()

View 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
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 我的占位M1 登录与配置S1 挂载登录/配置页)。 */
@AndroidEntryPoint
class MineFragment : PlaceholderFragment()

View 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)
}
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 统计占位M6 统计与上传S4 挂载统计表与文件上传)。 */
@AndroidEntryPoint
class StatsFragment : PlaceholderFragment()

View 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.LogLogCat 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"
}
}

View File

@@ -0,0 +1,7 @@
package com.stec.cmd
import dagger.hilt.android.AndroidEntryPoint
/** 任务占位M3 任务管理S2 挂载任务列表)。 */
@AndroidEntryPoint
class TaskFragment : PlaceholderFragment()

View 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
}

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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">占位页 —— 业务功能按 S1S4 计划经 FeatureMount 挂载</string>
</resources>

View 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>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
网络安全白名单:
- 默认全域强制 HTTPSusesCleartextTraffic=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>

View 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)
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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 位表示。
* 例:设置 1000x0064→ 整条命令 `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
}

View File

@@ -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
}
}

View File

@@ -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,
)

View File

@@ -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())
}
}

View File

@@ -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
View 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
View 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)
}

View 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)
}

View 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
}
}

View 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)
}

View 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) }
}
}
}

View 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)
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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>
}

View 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)
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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
}

View 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)
}

View File

@@ -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"
}
}

View File

@@ -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)
}
}
}

View File

@@ -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"
}
}

View File

@@ -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?
}

View File

@@ -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
}

12
gradle.properties Normal file
View 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
View File

@@ -0,0 +1,99 @@
# 版本目录Version Catalog全工程唯一版本事实源
# 选型原则全部采用相互兼容的已知稳定版AGP 8.7.3 需 Gradle ≥ 8.9wrapper 为 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

Binary file not shown.

View 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
View 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
View 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
View 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")