android: cloud module scaffold (retrofit, opt-in, disabled by default)

This commit is contained in:
ZXCLI
2026-09-11 00:22:25 +08:00
parent b7a928e23d
commit c34940e6fd
9 changed files with 206 additions and 3 deletions
+3
View File
@@ -48,6 +48,9 @@ dependencies {
implementation(libs.core.ktx) implementation(libs.core.ktx)
implementation(libs.lifecycle.runtime.ktx) implementation(libs.lifecycle.runtime.ktx)
implementation(libs.lifecycle.viewmodel.compose) 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) debugImplementation(libs.compose.ui.tooling)
testImplementation(libs.junit) testImplementation(libs.junit)
} }
+14
View File
@@ -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 var language: String
get() = sp.getString("locale", "auto") ?: "auto" get() = sp.getString("locale", "auto") ?: "auto"
set(v) = sp.edit().putString("locale", v).apply() 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.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -27,6 +28,8 @@ fun SettingsScreen() {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val settings = remember { AppSettings(context) } val settings = remember { AppSettings(context) }
var dialog by remember { mutableStateOf<String?>(null) } 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)) { Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" } SettingRow("默认调色板", Palettes.NAMES[settings.defaultPaletteIndex]) { dialog = "palette" }
@@ -36,6 +39,7 @@ fun SettingsScreen() {
) { dialog = "emissivity" } ) { dialog = "emissivity" }
SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" } SettingRow("报警温度", "%.1f℃".format(settings.alarmTempC / 10f)) { dialog = "alarm" }
SettingRow("语言", settings.language) { dialog = "language" } SettingRow("语言", settings.language) { dialog = "language" }
SettingRow("云同步", if (cloudEnabled) "已开启" else "已关闭") { dialog = "cloud" }
SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null } SettingRow("关于", "MAG160C 统一热像版 1.0.0") { dialog = null }
} }
@@ -90,6 +94,24 @@ fun SettingsScreen() {
initialC = settings.alarmTempC, initialC = settings.alarmTempC,
onDone = { settings.alarmTempC = it; dialog = null }, 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 -> {} 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("/"))
}
}
+3
View File
@@ -6,6 +6,7 @@ activityCompose = "1.10.1"
coreKtx = "1.16.0" coreKtx = "1.16.0"
lifecycle = "2.9.1" lifecycle = "2.9.1"
junit = "4.13.2" junit = "4.13.2"
retrofit = "2.11.0"
[libraries] [libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } 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-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" } lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
junit = { group = "junit", name = "junit", version.ref = "junit" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
Binary file not shown.
+9 -1
View File
@@ -405,7 +405,15 @@
case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。 case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。
新增 PalettesTest8 项);产物见 palette_extraction_findings.md。 新增 PalettesTest8 项);产物见 palette_extraction_findings.md。
APK 已更新。 APK 已更新。
- [ ] Phase D:云模块脚手架(Retrofit opt-in - [x] Phase D2026-09-10:云模块脚手架(Retrofit 2.11.0 opt-in,默认关闭):
cloud/CloudApi.kt(接口+DATA classCloudClient.api() 在未开启时直接
check() 抛异常——绝不静默联网);AppSettings.cloudEnabled(默认 false
构造时同步 CloudClient);设置页"语言"与"关于"之间新增"云同步"行 +
说明对话框("当前版本仅预留接口,不会发起任何网络请求");
proguard-rules.pro 新建(-keep cloud.**;原文件缺失但被 build.gradle
引用)。新增 CloudClientTest4 项,锁"默认关闭/api() 拒绝"契约)。
debug + release(R8) 双构建通过,29 单测全绿。APK 已更新。
- [ ] Phase E:可见光 PIP 融合
## 里程碑日志 ## 里程碑日志