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.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)
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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("/"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
@@ -405,7 +405,15 @@
|
|||||||
case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。
|
case12/13 对任何预览覆盖率≤6%)→ 保留近似并注明。
|
||||||
新增 PalettesTest(8 项);产物见 palette_extraction_findings.md。
|
新增 PalettesTest(8 项);产物见 palette_extraction_findings.md。
|
||||||
APK 已更新。
|
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