diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index befcb6c..cf34dd6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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) } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..dd6d50e --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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.** diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/cloud/CloudApi.kt b/android/app/src/main/kotlin/com/mag160c/thermal/cloud/CloudApi.kt new file mode 100644 index 0000000..ce42530 --- /dev/null +++ b/android/app/src/main/kotlin/com/mag160c/thermal/cloud/CloudApi.kt @@ -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 + + @GET("v1/account/devices") + fun devices(): Call + + @POST("v1/account/taskfiles/{taskId}") + fun upload(@Path("taskId") id: String, @Body body: RequestBody): Call + + @GET("v1/account/taskfiles/{taskId}") + fun taskfiles(@Path("taskId") id: String): Call +} + +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 = 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 = 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) + } +} diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt index 4b45927..cb21e24 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/AppSettings.kt @@ -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) + } } diff --git a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt index 07bb6c4..d9171be 100644 --- a/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/mag160c/thermal/ui/settings/SettingsScreen.kt @@ -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(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 -> {} } } diff --git a/android/app/src/test/kotlin/com/mag160c/thermal/cloud/CloudClientTest.kt b/android/app/src/test/kotlin/com/mag160c/thermal/cloud/CloudClientTest.kt new file mode 100644 index 0000000..0c48941 --- /dev/null +++ b/android/app/src/test/kotlin/com/mag160c/thermal/cloud/CloudClientTest.kt @@ -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("/")) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index df2f461..5a80252 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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" } diff --git a/build-artifacts/mag160c-app-debug.apk b/build-artifacts/mag160c-app-debug.apk index 1268144..7500412 100644 --- a/build-artifacts/mag160c-app-debug.apk +++ b/build-artifacts/mag160c-app-debug.apk @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2fa63ca3e15f99b75a39b3aa69552f7b8ade53d110cde56097bb042d4505088f -size 11925201 +oid sha256:e31763604680b4e45fbb444dbf96e6eac222f2e7b114f4a21e3c41267c6f4218 +size 12236705 diff --git a/docs/android_app/session_state.md b/docs/android_app/session_state.md index e5e937c..2ab9363 100644 --- a/docs/android_app/session_state.md +++ b/docs/android_app/session_state.md @@ -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 融合 ## 里程碑日志