android: cloud module scaffold (retrofit, opt-in, disabled by default)
This commit is contained in:
@@ -48,6 +48,9 @@ dependencies {
|
||||
implementation(libs.core.ktx)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
implementation(libs.lifecycle.viewmodel.compose)
|
||||
// Cloud scaffold (Phase D): opt-in only and disabled by default — see cloud/CloudApi.kt
|
||||
implementation(libs.retrofit)
|
||||
implementation(libs.retrofit.converter.gson)
|
||||
debugImplementation(libs.compose.ui.tooling)
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
# MAG160C release (R8) keep rules.
|
||||
|
||||
# Cloud scaffold (Phase D): the Retrofit interface + DTOs are only reached via
|
||||
# reflection (Retrofit proxies, Gson field mapping), so keep them intact.
|
||||
-keep class com.mag160c.thermal.cloud.** { *; }
|
||||
-keepattributes Signature
|
||||
-keepattributes *Annotation*
|
||||
|
||||
# Gson's reflective type adapters for the cloud DTOs.
|
||||
-keep class com.google.gson.** { *; }
|
||||
-dontwarn com.google.gson.**
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-dontwarn retrofit2.**
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.mag160c.thermal.cloud
|
||||
|
||||
import okhttp3.RequestBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
|
||||
/**
|
||||
* Cloud API scaffold (Phase D) — INTERFACE ONLY, NO NETWORK BEHAVIOUR.
|
||||
*
|
||||
* The official professional app talks to cloudapi.magnity.com.cn; this module
|
||||
* reserves the same shape so the feature can be filled in later without
|
||||
* reworking the app. Design rules, per the user's decision (2026-09-06):
|
||||
*
|
||||
* - disabled by default; enabling is an explicit per-device opt-in
|
||||
* - nothing here is ever called unless [CloudClient.enabled] is true, and
|
||||
* [CloudClient.api] refuses to build a Retrofit client otherwise
|
||||
* - no background sync, no analytics, no silent uploads
|
||||
*
|
||||
* Endpoint shapes mirror the vendor service but are placeholders: request and
|
||||
* response fields will need to be aligned with the real service before use.
|
||||
*/
|
||||
interface CloudApi {
|
||||
@POST("v1/account/login")
|
||||
fun login(@Body body: LoginReq): Call<LoginResp>
|
||||
|
||||
@GET("v1/account/devices")
|
||||
fun devices(): Call<DeviceListResp>
|
||||
|
||||
@POST("v1/account/taskfiles/{taskId}")
|
||||
fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call<UploadResp>
|
||||
|
||||
@GET("v1/account/taskfiles/{taskId}")
|
||||
fun taskfiles(@Path("taskId") id: String): Call<TaskFilesResp>
|
||||
}
|
||||
|
||||
data class LoginReq(val account: String, val password: String)
|
||||
|
||||
data class LoginResp(val code: Int = 0, val token: String? = null, val message: String? = null)
|
||||
|
||||
data class DeviceListResp(val code: Int = 0, val devices: List<DeviceInfo> = emptyList())
|
||||
|
||||
data class DeviceInfo(
|
||||
val serial: String = "",
|
||||
val model: String = "",
|
||||
val name: String = "",
|
||||
)
|
||||
|
||||
data class UploadResp(val code: Int = 0, val fileId: String? = null, val message: String? = null)
|
||||
|
||||
data class TaskFilesResp(val code: Int = 0, val files: List<TaskFile> = emptyList())
|
||||
|
||||
data class TaskFile(
|
||||
val fileId: String = "",
|
||||
val name: String = "",
|
||||
val size: Long = 0,
|
||||
val createdAt: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Client factory. [enabled] reads the user's opt-in flag; [api] must not be
|
||||
* called while disabled (it throws rather than silently reaching the network,
|
||||
* so an accidental call is caught in testing instead of shipping data).
|
||||
*/
|
||||
object CloudClient {
|
||||
const val BASE_URL = "https://cloudapi.magnity.com.cn/"
|
||||
|
||||
/** Backed by the settings flag; the caller supplies the value from AppSettings. */
|
||||
@Volatile
|
||||
private var optIn = false
|
||||
|
||||
val enabled: Boolean get() = optIn
|
||||
|
||||
fun setEnabled(value: Boolean) {
|
||||
optIn = value
|
||||
}
|
||||
|
||||
fun api(): CloudApi {
|
||||
check(optIn) {
|
||||
"cloud is disabled: CloudClient.api() must not be called unless the user opted in"
|
||||
}
|
||||
return Retrofit.Builder()
|
||||
.baseUrl(BASE_URL)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build()
|
||||
.create(CloudApi::class.java)
|
||||
}
|
||||
}
|
||||
@@ -21,4 +21,19 @@ class AppSettings(context: Context) {
|
||||
var language: String
|
||||
get() = sp.getString("locale", "auto") ?: "auto"
|
||||
set(v) = sp.edit().putString("locale", v).apply()
|
||||
|
||||
/**
|
||||
* Cloud sync opt-in. Default OFF and never flipped programmatically:
|
||||
* the whole cloud module stays inert until the user turns this on.
|
||||
*/
|
||||
var cloudEnabled: Boolean
|
||||
get() = sp.getBoolean("cloudEnabled", false)
|
||||
set(v) {
|
||||
sp.edit().putBoolean("cloudEnabled", v).apply()
|
||||
com.mag160c.thermal.cloud.CloudClient.setEnabled(v)
|
||||
}
|
||||
|
||||
init {
|
||||
com.mag160c.thermal.cloud.CloudClient.setEnabled(cloudEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -27,6 +28,8 @@ fun SettingsScreen() {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val settings = remember { AppSettings(context) }
|
||||
var dialog by remember { mutableStateOf<String?>(null) }
|
||||
// mirror the persisted flag in Compose state so the row label updates
|
||||
var cloudEnabled by remember { mutableStateOf(settings.cloudEnabled) }
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
|
||||
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
|
||||
@@ -36,6 +39,7 @@ fun SettingsScreen() {
|
||||
) { dialog = "emissivity" }
|
||||
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
|
||||
SettingRow("语言", settings.language) { dialog = "language" }
|
||||
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
|
||||
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
|
||||
}
|
||||
|
||||
@@ -90,6 +94,24 @@ fun SettingsScreen() {
|
||||
initialC = settings.alarmTempC,
|
||||
onDone = { settings.alarmTempC = it; dialog = null },
|
||||
)
|
||||
"cloud" -> AlertDialog(
|
||||
onDismissRequest = { dialog = null },
|
||||
title = { Text("云同步") },
|
||||
text = {
|
||||
Text(
|
||||
"上传/任务同步需要账号,当前版本仅预留接口,不会发起任何网络请求。",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
cloudEnabled = !cloudEnabled
|
||||
settings.cloudEnabled = cloudEnabled
|
||||
dialog = null
|
||||
}) {
|
||||
Text(if (cloudEnabled) "保持关闭" else "开启")
|
||||
}
|
||||
},
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.mag160c.thermal.cloud
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Phase D contract: the cloud module is inert unless the user opts in.
|
||||
* [CloudClient.api] must refuse to build a Retrofit client while disabled, so an
|
||||
* accidental call is caught in tests rather than shipping data off-device.
|
||||
*/
|
||||
class CloudClientTest {
|
||||
@Test
|
||||
fun disabledByDefault() {
|
||||
CloudClient.setEnabled(false)
|
||||
assertFalse("cloud must start disabled", CloudClient.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun apiRefusesWhileDisabled() {
|
||||
CloudClient.setEnabled(false)
|
||||
try {
|
||||
CloudClient.api()
|
||||
fail("api() must throw while the cloud is disabled")
|
||||
} catch (e: IllegalStateException) {
|
||||
assertTrue(e.message!!.contains("disabled"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enablingIsExplicitAndReversible() {
|
||||
CloudClient.setEnabled(true)
|
||||
assertTrue(CloudClient.enabled)
|
||||
// a Retrofit client can now be constructed (no request is made here)
|
||||
CloudClient.api()
|
||||
CloudClient.setEnabled(false)
|
||||
assertFalse(CloudClient.enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun baseUrlPointsAtTheVendorCloud() {
|
||||
assertTrue(CloudClient.BASE_URL.startsWith("https://"))
|
||||
assertTrue(CloudClient.BASE_URL.endsWith("/"))
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ activityCompose = "1.10.1"
|
||||
coreKtx = "1.16.0"
|
||||
lifecycle = "2.9.1"
|
||||
junit = "4.13.2"
|
||||
retrofit = "2.11.0"
|
||||
|
||||
[libraries]
|
||||
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||
@@ -20,6 +21,8 @@ core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx"
|
||||
lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||
retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
Binary file not shown.
@@ -405,7 +405,15 @@
|
||||
case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。
|
||||
新增 PalettesTest(8 项);产物见 palette_extraction_findings.md。
|
||||
APK 已更新。
|
||||
- [ ] Phase D:云模块脚手架(Retrofit opt-in)
|
||||
- [x] Phase D(2026-09-10):云模块脚手架(Retrofit 2.11.0 opt-in,默认关闭):
|
||||
cloud/CloudApi.kt(接口+DATA class,CloudClient.api() 在未开启时直接
|
||||
check() 抛异常——绝不静默联网);AppSettings.cloudEnabled(默认 false,
|
||||
构造时同步 CloudClient);设置页"语言"与"关于"之间新增"云同步"行 +
|
||||
说明对话框("当前版本仅预留接口,不会发起任何网络请求");
|
||||
proguard-rules.pro 新建(-keep cloud.**;原文件缺失但被 build.gradle
|
||||
引用)。新增 CloudClientTest(4 项,锁"默认关闭/api() 拒绝"契约)。
|
||||
debug + release(R8) 双构建通过,29 单测全绿。APK 已更新。
|
||||
- [ ] Phase E:可见光 PIP 融合
|
||||
|
||||
|
||||
## 里程碑日志
|
||||
|
||||
Reference in New Issue
Block a user