From 904f8b712fd002ef8234aaacf82370889a4b4cdd Mon Sep 17 00:00:00 2001 From: Frankie Gonzalez Date: Sun, 12 Jul 2026 21:45:37 -0700 Subject: [PATCH 1/6] Phase 1.6: Add saved pools and secure reconnect Encrypt successful ScreenLogic connections in an AES-GCM vault under noBackupFilesDir using an Android Keystore key (alias westcurrent.saved_pools.aes.v1). Recent pools, one-tap reconnect, password-only prompts, rename/remove, and fail-closed vault recovery. Consent defaults are off. Passwords are optional and never land in ordinary preferences, logs, or UI state. Existing SiteSession remains the only connection engine. No auto-connect on launch. No equipment features added. Fable did not implement security or architecture. --- app/src/main/AndroidManifest.xml | 2 + .../com/pentaircontrol/pool/MainActivity.kt | 33 +- .../savedpools/AndroidKeystoreVaultCipher.kt | 120 ++++ .../pool/savedpools/ConnectSaveOptions.kt | 39 ++ .../pool/savedpools/SavedPoolCodec.kt | 187 +++++++ .../pool/savedpools/SavedPoolModels.kt | 163 ++++++ .../pool/savedpools/SavedPoolRepository.kt | 513 ++++++++++++++++++ .../savedpools/SavedPoolRepositoryFactory.kt | 52 ++ .../pool/savedpools/VaultCipher.kt | 101 ++++ .../pool/savedpools/VaultFileStore.kt | 144 +++++ .../pool/session/SiteSession.kt | 14 + .../pool/settings/AppPreferencesStore.kt | 9 +- .../java/com/pentaircontrol/pool/ui/AppNav.kt | 44 +- .../pentaircontrol/pool/ui/PoolViewModel.kt | 452 ++++++++++++++- .../pool/ui/preview/ScreenPreviews.kt | 12 +- .../pool/ui/screens/ConnectScreen.kt | 492 ++++++++++++++++- .../pool/ui/screens/SettingsScreen.kt | 219 +++++++- app/src/main/res/xml/backup_rules.xml | 25 +- .../main/res/xml/data_extraction_rules.xml | 30 +- .../savedpools/AesGcmVaultEnvelopeTest.kt | 152 ++++++ .../pool/savedpools/SavedPoolCodecTest.kt | 204 +++++++ .../SavedPoolConnectionBehaviorTest.kt | 350 ++++++++++++ .../savedpools/SavedPoolRepositoryTest.kt | 362 ++++++++++++ .../SavedPoolSecurityAndBackupTest.kt | 190 +++++++ .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 305 +++++++++++ 25 files changed, 4131 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt create mode 100644 app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt create mode 100644 docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ad7378..1b10f99 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ vm.connectRemote(code, pass) }, + onConnect = { gw, pass, save -> vm.connect(gw, pass, save) }, + onManualConnect = { ip, port, pass, save -> + vm.connectManual(ip, port, pass, save) + }, + onRemoteConnect = { code, pass, save -> + vm.connectRemote(code, pass, save) + }, onSimulator = vm::connectSimulator, onDisconnect = vm::disconnect, onToggleCircuit = vm::toggleCircuit, @@ -86,7 +94,24 @@ class MainActivity : ComponentActivity() { canRememberTemperatureUnit = canRememberUnit, canRetryConnection = canRetry, onRetryConnection = vm::retryConnection, - onClearStatusMessage = vm::clearStatusMessage + onClearStatusMessage = vm::clearStatusMessage, + savedPoolsState = savedPoolsState, + passwordPrompt = passwordPrompt, + onConnectSavedPool = vm::connectSavedPool, + onSubmitSavedPassword = { id, pass, remember -> + vm.submitSavedPoolPassword(id, pass, remember) + }, + onDismissPasswordPrompt = vm::dismissPasswordPrompt, + onRenameSavedPool = vm::renameSavedPool, + onDeleteSavedPool = vm::deleteSavedPool, + onSaveCurrentPool = { vm.saveCurrentConnectedPool(rememberPassword = false) }, + onMarkPreferredSavedPool = vm::markPreferredSavedPool, + onRetryVault = vm::refreshSavedPools, + onResetVault = vm::resetSavedPoolsVault, + // Diagnostics only — no secrets, no "show secret" affordance. + vaultDiagnostics = + "schema=v1 · alias=${AndroidKeystoreVaultCipher.KEY_ALIAS} · " + + "file=${AtomicVaultFileStore.VAULT_FILE_NAME} · noBackup" ) } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt new file mode 100644 index 0000000..a5c9b73 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt @@ -0,0 +1,120 @@ +package com.pentaircontrol.pool.savedpools + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import java.security.KeyStore +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +/** + * AES-256-GCM vault cipher using the Android Keystore. + * + * Alias: [KEY_ALIAS] + * Does not require StrongBox or user authentication. + * Key material never leaves the Keystore; never hard-coded. + * Keystore operations must not run on the main thread (callers use Dispatchers.IO). + */ +class AndroidKeystoreVaultCipher( + private val keyAlias: String = KEY_ALIAS +) : VaultCipher { + + override fun encrypt(plaintext: ByteArray): ByteArray { + val key = getOrCreateKey() + return AesGcmVaultEnvelope.encrypt(key, plaintext) + } + + override fun decrypt(envelope: ByteArray): ByteArray { + val key = try { + getExistingKey() + } catch (e: Exception) { + throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing", e) + } ?: throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing") + return try { + AesGcmVaultEnvelope.decrypt(key, envelope) + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + } + } + + fun deleteKey() { + try { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE) + ks.load(null) + if (ks.containsAlias(keyAlias)) { + ks.deleteEntry(keyAlias) + } + } catch (_: Exception) { + } + } + + fun keyExists(): Boolean { + return try { + getExistingKey() != null + } catch (_: Exception) { + false + } + } + + /** Optional diagnostic only — never log key material. */ + fun securityLevelLabel(): String { + return try { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE) + ks.load(null) + if (ks.containsAlias(keyAlias)) "AndroidKeyStore" else "missing" + } catch (_: Exception) { + "unavailable" + } + } + + private fun getOrCreateKey(): SecretKey { + getExistingKey()?.let { return it } + + // Prefer StrongBox when available (API 28+); never require it. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + return generateKey(strongBox = true) + } catch (_: Exception) { + // Fall through to TEE / software Keystore. + } + } + return generateKey(strongBox = false) + } + + private fun generateKey(strongBox: Boolean): SecretKey { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + val builder = KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setKeySize(256) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .setUserAuthenticationRequired(false) + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + keyGenerator.init(builder.build()) + return keyGenerator.generateKey() + } + + private fun getExistingKey(): SecretKey? { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE) + ks.load(null) + if (!ks.containsAlias(keyAlias)) return null + val entry = ks.getEntry(keyAlias, null) as? KeyStore.SecretKeyEntry + ?: return null + return entry.secretKey + } + + companion object { + const val ANDROID_KEYSTORE = "AndroidKeyStore" + const val KEY_ALIAS = "westcurrent.saved_pools.aes.v1" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt new file mode 100644 index 0000000..2823988 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt @@ -0,0 +1,39 @@ +package com.pentaircontrol.pool.savedpools + +/** + * Explicit user consent for saving a successful connection. + * Defaults are intentionally off — nothing is stored without opt-in. + * + * Contains no secrets. Passwords / system codes stay in the connection call only. + */ +class ConnectSaveOptions( + val savePool: Boolean = false, + val rememberPassword: Boolean = false +) { + override fun toString(): String = + "ConnectSaveOptions(savePool=$savePool, rememberPassword=$rememberPassword)" +} + +/** + * Non-secret UI state for a password-only reconnect prompt. + * Never carries system code, address, or decrypted route material. + */ +class SavedPoolPasswordPrompt( + val profileId: SavedPoolId, + val displayName: String, + val routeKind: SavedRouteKind +) { + override fun toString(): String = + "SavedPoolPasswordPrompt(id=$profileId, name=$displayName, kind=$routeKind)" +} + +/** + * Vault health presented to UI — never includes ciphertext or plaintext. + */ +sealed class SavedPoolsListState { + data object Loading : SavedPoolsListState() + data class Available(val pools: List) : SavedPoolsListState() + data class Unreadable(val health: VaultHealth) : SavedPoolsListState() { + override fun toString(): String = "Unreadable(health=$health)" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt new file mode 100644 index 0000000..7611047 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt @@ -0,0 +1,187 @@ +package com.pentaircontrol.pool.savedpools + +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.DataInputStream +import java.io.DataOutputStream +import java.nio.charset.StandardCharsets + +/** + * Versioned pure-Kotlin codec for the decrypted saved-pools vault payload. + * Rejects unknown versions and truncated/malformed data without excessive allocation. + */ +object SavedPoolCodec { + const val SCHEMA_VERSION: Int = 1 + private const val MAGIC = "WCSV" + private const val MAX_PROFILES = 50 + private const val MAX_STRING = 4_096 + private const val MAX_PAYLOAD = 512_000 + + fun encode(profiles: List): ByteArray { + require(profiles.size <= MAX_PROFILES) { "Too many profiles" } + val ids = profiles.map { it.id.value }.toSet() + require(ids.size == profiles.size) { "Duplicate profile ids" } + val baos = ByteArrayOutputStream() + DataOutputStream(baos).use { out -> + out.writeBytes(MAGIC) + out.writeShort(SCHEMA_VERSION) + out.writeInt(profiles.size) + for (p in profiles) { + writeString(out, p.id.value) + writeString(out, p.displayName) + writeString(out, p.adapterId) + writeString(out, p.controllerIdentity ?: "") + out.writeLong(p.createdAtMs) + out.writeLong(p.lastSuccessAtMs) + out.writeUTF(p.preferredRoute.name) + out.writeInt(p.routes.size) + for (r in p.routes) { + when (r) { + is SavedRoute.ScreenLogicRemote -> { + out.writeUTF("sl_remote") + writeString(out, r.systemCode) + writeString(out, r.password ?: "") + out.writeBoolean(r.passwordRequired) + out.writeBoolean(r.blankPasswordSucceeded) + } + is SavedRoute.ScreenLogicLocal -> { + out.writeUTF("sl_local") + writeString(out, r.address) + out.writeInt(r.port) + writeString(out, r.gatewayLabel) + writeString(out, r.password ?: "") + out.writeBoolean(r.passwordRequired) + out.writeBoolean(r.blankPasswordSucceeded) + writeString(out, r.controllerIdentity ?: "") + } + } + } + } + } + val bytes = baos.toByteArray() + require(bytes.size <= MAX_PAYLOAD) { "Payload too large" } + return bytes + } + + fun decode(bytes: ByteArray): List { + if (bytes.size > MAX_PAYLOAD) { + throw VaultException(VaultHealth.CORRUPT, "Payload exceeds size limit") + } + if (bytes.size < 8) { + throw VaultException(VaultHealth.CORRUPT, "Truncated vault payload") + } + return try { + DataInputStream(ByteArrayInputStream(bytes)).use { inp -> + val magic = ByteArray(4) + inp.readFully(magic) + if (String(magic, StandardCharsets.US_ASCII) != MAGIC) { + throw VaultException(VaultHealth.CORRUPT, "Bad vault magic") + } + val version = inp.readUnsignedShort() + if (version != SCHEMA_VERSION) { + throw VaultException( + VaultHealth.UNSUPPORTED_SCHEMA, + "Unsupported vault schema $version" + ) + } + val count = inp.readInt() + if (count < 0 || count > MAX_PROFILES) { + throw VaultException(VaultHealth.CORRUPT, "Invalid profile count") + } + val profiles = ArrayList(count) + val seen = HashSet() + repeat(count) { + val id = readString(inp) + if (!seen.add(id)) { + throw VaultException(VaultHealth.CORRUPT, "Duplicate profile id") + } + val displayName = readString(inp) + val adapterId = readString(inp) + val controllerId = readString(inp).ifEmpty { null } + val created = inp.readLong() + val lastSuccess = inp.readLong() + val preferred = SavedRouteKind.valueOf(inp.readUTF()) + val routeCount = inp.readInt() + if (routeCount < 0 || routeCount > 8) { + throw VaultException(VaultHealth.CORRUPT, "Invalid route count") + } + val routes = mutableListOf() + repeat(routeCount) { + when (val type = inp.readUTF()) { + "sl_remote" -> { + val code = readString(inp) + val password = readString(inp).ifEmpty { null } + val passwordRequired = inp.readBoolean() + val blankOk = inp.readBoolean() + routes += SavedRoute.ScreenLogicRemote( + systemCode = code, + password = password, + passwordRequired = passwordRequired, + blankPasswordSucceeded = blankOk + ) + } + "sl_local" -> { + val address = readString(inp) + val port = inp.readInt() + val label = readString(inp) + val password = readString(inp).ifEmpty { null } + val passwordRequired = inp.readBoolean() + val blankOk = inp.readBoolean() + val cid = readString(inp).ifEmpty { null } + routes += SavedRoute.ScreenLogicLocal( + address = address, + port = port, + gatewayLabel = label, + password = password, + passwordRequired = passwordRequired, + blankPasswordSucceeded = blankOk, + controllerIdentity = cid + ) + } + else -> throw VaultException( + VaultHealth.UNSUPPORTED_SCHEMA, + "Unknown route type" + ) + } + } + profiles += SavedPoolProfile( + id = SavedPoolId(id), + displayName = displayName, + adapterId = adapterId, + controllerIdentity = controllerId, + createdAtMs = created, + lastSuccessAtMs = lastSuccess, + preferredRoute = preferred, + routes = routes + ) + } + if (inp.available() != 0) { + // Trailing bytes are suspicious but not always fatal; reject for safety. + throw VaultException(VaultHealth.CORRUPT, "Trailing vault data") + } + profiles + } + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + throw VaultException(VaultHealth.CORRUPT, "Failed to decode vault", e) + } + } + + private fun writeString(out: DataOutputStream, value: String) { + val raw = value.toByteArray(StandardCharsets.UTF_8) + require(raw.size <= MAX_STRING) { "String too long" } + out.writeInt(raw.size) + out.write(raw) + } + + private fun readString(inp: DataInputStream): String { + val len = inp.readInt() + if (len < 0 || len > MAX_STRING) { + throw VaultException(VaultHealth.CORRUPT, "Invalid string length") + } + val raw = ByteArray(len) + inp.readFully(raw) + return String(raw, StandardCharsets.UTF_8) + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt new file mode 100644 index 0000000..e7b16d2 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt @@ -0,0 +1,163 @@ +package com.pentaircontrol.pool.savedpools + +import java.util.UUID + +/** Opaque stable profile identifier. Not derived from credentials. */ +@JvmInline +value class SavedPoolId(val value: String) { + override fun toString(): String = value + + companion object { + fun generate(): SavedPoolId = SavedPoolId(UUID.randomUUID().toString()) + } +} + +/** + * Non-secret UI summary. Never contains system code, password, full address, or routes. + */ +class SavedPoolSummary( + val id: SavedPoolId, + val displayName: String, + val adapterId: String, + val routeKindLabel: String, + val lastSuccessAtMs: Long, + val passwordRequired: Boolean, + val readyToConnect: Boolean, + val hasLocalRoute: Boolean, + val hasRemoteRoute: Boolean +) { + override fun toString(): String = + "SavedPoolSummary(id=$id, name=$displayName, route=$routeKindLabel, " + + "passwordRequired=$passwordRequired, ready=$readyToConnect)" +} + +/** + * Internal vault profile. Secrets live only inside [routes] while decrypted in memory. + * Not a data class — [toString] is redacted. + */ +class SavedPoolProfile( + val id: SavedPoolId, + var displayName: String, + val adapterId: String, + var controllerIdentity: String?, + val createdAtMs: Long, + var lastSuccessAtMs: Long, + var preferredRoute: SavedRouteKind, + var routes: MutableList +) { + fun passwordIsStored(): Boolean = routes.any { it.hasPassword } + fun passwordRequiredForConnect(kind: SavedRouteKind = preferredRoute): Boolean { + val route = routes.find { it.kind == kind } ?: routes.firstOrNull() ?: return true + return route.passwordRequired && !route.hasPassword + } + + fun readyToConnect(kind: SavedRouteKind = preferredRoute): Boolean { + val route = routes.find { it.kind == kind } ?: routes.firstOrNull() ?: return false + return !route.passwordRequired || route.hasPassword + } + + override fun toString(): String = + "SavedPoolProfile(id=$id, name=$displayName, adapter=$adapterId, " + + "routes=${routes.size}, controllerId=${controllerIdentity?.let { "***" }}, secrets=***)" +} + +enum class SavedRouteKind { SCREENLOGIC_REMOTE, SCREENLOGIC_LOCAL } + +/** + * Vendor-owned encrypted route payload. Secrets redacted in [toString]. + */ +sealed class SavedRoute { + abstract val kind: SavedRouteKind + abstract val hasPassword: Boolean + /** True when a non-blank password is required for this route and is not stored. */ + abstract val passwordRequired: Boolean + + class ScreenLogicRemote( + val systemCode: String, + val password: String?, + override val passwordRequired: Boolean, + val blankPasswordSucceeded: Boolean + ) : SavedRoute() { + override val kind: SavedRouteKind = SavedRouteKind.SCREENLOGIC_REMOTE + override val hasPassword: Boolean get() = !password.isNullOrEmpty() + override fun toString(): String = + "ScreenLogicRemote(systemCode=***, password=${if (hasPassword) "***" else "none"}, " + + "passwordRequired=$passwordRequired)" + } + + class ScreenLogicLocal( + val address: String, + val port: Int, + val gatewayLabel: String, + val password: String?, + override val passwordRequired: Boolean, + val blankPasswordSucceeded: Boolean, + val controllerIdentity: String? + ) : SavedRoute() { + override val kind: SavedRouteKind = SavedRouteKind.SCREENLOGIC_LOCAL + override val hasPassword: Boolean get() = !password.isNullOrEmpty() + override fun toString(): String = + "ScreenLogicLocal(address=***, port=***, label=$gatewayLabel, " + + "password=${if (hasPassword) "***" else "none"}, passwordRequired=$passwordRequired)" + } +} + +/** Draft captured from a successful connection for optional save. */ +class SavedPoolDraft( + val displayName: String, + val adapterId: String, + val controllerIdentity: String?, + val route: SavedRoute, + val preferredRoute: SavedRouteKind, + val rememberPassword: Boolean, + val timestampMs: Long = System.currentTimeMillis() +) { + override fun toString(): String = + "SavedPoolDraft(name=$displayName, adapter=$adapterId, route=${route.kind}, " + + "rememberPassword=$rememberPassword, secrets=***)" +} + +sealed class SavedPoolConnectionResult { + class Ready( + val profileId: SavedPoolId, + val displayName: String, + /** Transient — caller must not put this in StateFlow. */ + val connectionTarget: com.pentaircontrol.pool.domain.AdapterConnectionTarget + ) : SavedPoolConnectionResult() { + override fun toString(): String = + "Ready(profileId=$profileId, name=$displayName, target=***)" + } + + class PasswordRequired( + val profileId: SavedPoolId, + val displayName: String, + val routeKind: SavedRouteKind + ) : SavedPoolConnectionResult() + + class NotFound(val profileId: SavedPoolId) : SavedPoolConnectionResult() + class VaultUnavailable(val health: VaultHealth) : SavedPoolConnectionResult() + class IdentityMismatch(val message: String) : SavedPoolConnectionResult() + class Failure(val message: String) : SavedPoolConnectionResult() { + override fun toString(): String = "Failure(message=$message)" + } +} + +enum class VaultHealth { + AVAILABLE, + EMPTY, + KEYSTORE_MISSING, + KEY_INVALIDATED, + AUTH_TAG_FAILURE, + UNSUPPORTED_SCHEMA, + CORRUPT, + IO_FAILURE, + LOCKED_OR_UNAVAILABLE +} + +class VaultException( + val health: VaultHealth, + message: String, + cause: Throwable? = null +) : Exception(message, cause) { + override fun toString(): String = "VaultException(health=$health, message=$message)" +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt new file mode 100644 index 0000000..2862efb --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt @@ -0,0 +1,513 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicIds +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.settings.AppPreferencesStore +import com.pentaircontrol.pool.settings.ControllerTempUnitStore +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +interface SavedPoolRepository { + suspend fun health(): VaultHealth + suspend fun list(): List + suspend fun loadConnection( + profileId: SavedPoolId, + passwordOverride: String? = null, + preferredKind: SavedRouteKind? = null + ): SavedPoolConnectionResult + + /** + * Expected controller identity for a profile, if known. + * Used only to reject a mismatched local reconnect — never as an encryption key. + */ + suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? + + suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SavedPoolSummary + suspend fun rename(profileId: SavedPoolId, displayName: String) + suspend fun recordSuccessfulConnection(profileId: SavedPoolId, timestampMs: Long) + suspend fun delete(profileId: SavedPoolId) + suspend fun updatePasswordAfterSuccess( + profileId: SavedPoolId, + kind: SavedRouteKind, + password: String?, + rememberPassword: Boolean + ) + /** Bump last-success so this profile sorts first among recent pools. */ + suspend fun markPreferredRecent(profileId: SavedPoolId, timestampMs: Long = System.currentTimeMillis()) + /** + * After a successful local reconnect via a new address (e.g. DHCP change + discovery), + * update only the local route address/port for [profileId]. + */ + suspend fun updateLocalAddress( + profileId: SavedPoolId, + address: String, + port: Int, + gatewayLabel: String, + controllerIdentity: String? + ) + /** + * Explicit user-confirmed vault wipe: deletes encrypted file and invokes key reset. + * Does not erase appearance or unrelated preferences. + */ + suspend fun resetVault() +} + +/** + * Thread-safe repository: encrypt entire profile list on each write. + * Fail-closed: never returns partial secrets on vault failure. + */ +class DefaultSavedPoolRepository( + private val cipher: VaultCipher, + private val store: VaultFileStore, + private val preferences: AppPreferencesStore? = null, + private val maxProfiles: Int = 40, + /** Called during [resetVault] to delete the Keystore alias (production only). */ + private val onKeyReset: (() -> Unit)? = null +) : SavedPoolRepository { + + private val mutex = Mutex() + private var cached: List? = null + private var lastHealth: VaultHealth = VaultHealth.EMPTY + + override suspend fun health(): VaultHealth = mutex.withLock { + try { + loadLocked() + lastHealth + } catch (e: VaultException) { + lastHealth = e.health + e.health + } + } + + override suspend fun list(): List = mutex.withLock { + try { + loadLocked() + .sortedByDescending { it.lastSuccessAtMs } + .map { it.toSummary() } + } catch (e: VaultException) { + lastHealth = e.health + if (e.health == VaultHealth.EMPTY) emptyList() + else throw e + } + } + + override suspend fun loadConnection( + profileId: SavedPoolId, + passwordOverride: String?, + preferredKind: SavedRouteKind? + ): SavedPoolConnectionResult = mutex.withLock { + val profiles = try { + loadLocked() + } catch (e: VaultException) { + return SavedPoolConnectionResult.VaultUnavailable(e.health) + } + val profile = profiles.find { it.id == profileId } + ?: return SavedPoolConnectionResult.NotFound(profileId) + val kind = preferredKind ?: profile.preferredRoute + val route = profile.routes.find { it.kind == kind } ?: profile.routes.firstOrNull() + ?: return SavedPoolConnectionResult.Failure("No connection route saved") + + when (route) { + is SavedRoute.ScreenLogicRemote -> { + val password = passwordOverride ?: route.password + if (needsPasswordPrompt(route, password)) { + return SavedPoolConnectionResult.PasswordRequired( + profileId, profile.displayName, route.kind + ) + } + val target = ScreenLogicRemoteTarget( + systemCode = route.systemCode, + password = password.orEmpty() + ) + SavedPoolConnectionResult.Ready(profileId, profile.displayName, target) + } + is SavedRoute.ScreenLogicLocal -> { + val password = passwordOverride ?: route.password + if (needsPasswordPrompt(route, password)) { + return SavedPoolConnectionResult.PasswordRequired( + profileId, profile.displayName, route.kind + ) + } + val gw = DiscoveredGateway( + id = "saved-local", + displayName = route.gatewayLabel.ifBlank { "Saved local pool" }, + address = route.address, + port = route.port + ) + val target = ScreenLogicLocalTarget(gw, password.orEmpty()) + SavedPoolConnectionResult.Ready(profileId, profile.displayName, target) + } + } + } + + override suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? = + mutex.withLock { + try { + loadLocked().find { it.id == profileId }?.controllerIdentity + } catch (_: VaultException) { + null + } + } + + override suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SavedPoolSummary = + mutex.withLock { + val profiles = try { + loadLocked().toMutableList() + } catch (e: VaultException) { + if (e.health == VaultHealth.EMPTY) mutableListOf() + else throw e + } + + val route = normalizeRoute(draft.route, draft.rememberPassword) + val identity = draft.controllerIdentity?.takeIf { it.isNotBlank() } + + val existing = when { + identity != null -> + profiles.find { + it.controllerIdentity == identity && it.adapterId == draft.adapterId + } + else -> null + } + + val profile: SavedPoolProfile + if (existing != null) { + profile = existing + // Never overwrite a user-facing name on merge; only routes / times update. + mergeRoute(profile, route) + if (identity != null) profile.controllerIdentity = identity + profile.lastSuccessAtMs = draft.timestampMs + profile.preferredRoute = draft.preferredRoute + } else { + if (profiles.size >= maxProfiles) { + // Drop oldest by last success + profiles.sortBy { it.lastSuccessAtMs } + profiles.removeAt(0) + } + profile = SavedPoolProfile( + id = SavedPoolId.generate(), + displayName = draft.displayName.ifBlank { "Saved pool" }, + adapterId = draft.adapterId, + controllerIdentity = identity, + createdAtMs = draft.timestampMs, + lastSuccessAtMs = draft.timestampMs, + preferredRoute = draft.preferredRoute, + routes = mutableListOf(route) + ) + profiles += profile + } + persistLocked(profiles) + profile.toSummary() + } + + override suspend fun rename(profileId: SavedPoolId, displayName: String) = mutex.withLock { + val name = displayName.trim() + require(name.isNotEmpty()) { "Name required" } + val profiles = loadLocked().toMutableList() + val p = profiles.find { it.id == profileId } ?: return@withLock + p.displayName = name + // mark as user-renamed via preference flag is optional; name is enough + persistLocked(profiles) + } + + override suspend fun recordSuccessfulConnection(profileId: SavedPoolId, timestampMs: Long) = + mutex.withLock { + val profiles = loadLocked().toMutableList() + val p = profiles.find { it.id == profileId } ?: return@withLock + p.lastSuccessAtMs = timestampMs + persistLocked(profiles) + } + + override suspend fun delete(profileId: SavedPoolId) = mutex.withLock { + val profiles = try { + loadLocked().toMutableList() + } catch (e: VaultException) { + if (e.health == VaultHealth.EMPTY) return@withLock + throw e + } + val removed = profiles.find { it.id == profileId } + profiles.removeAll { it.id == profileId } + if (profiles.isEmpty()) { + store.delete() + cached = emptyList() + lastHealth = VaultHealth.EMPTY + } else { + persistLocked(profiles) + } + // Clear per-controller temp unit when identity known + val identity = removed?.controllerIdentity + if (identity != null && preferences != null) { + ControllerTempUnitStore.clear(preferences, identity) + } + } + + override suspend fun updatePasswordAfterSuccess( + profileId: SavedPoolId, + kind: SavedRouteKind, + password: String?, + rememberPassword: Boolean + ) = mutex.withLock { + val profiles = loadLocked().toMutableList() + val p = profiles.find { it.id == profileId } ?: return@withLock + val idx = p.routes.indexOfFirst { it.kind == kind } + if (idx < 0) return@withLock + when (val r = p.routes[idx]) { + is SavedRoute.ScreenLogicRemote -> { + p.routes[idx] = r.copyRoute( + password = if (rememberPassword) password else null, + passwordRequired = !password.isNullOrEmpty() && !rememberPassword, + blankPasswordSucceeded = password.isNullOrEmpty() + ) + } + is SavedRoute.ScreenLogicLocal -> { + p.routes[idx] = r.copyRoute( + password = if (rememberPassword) password else null, + passwordRequired = !password.isNullOrEmpty() && !rememberPassword, + blankPasswordSucceeded = password.isNullOrEmpty() + ) + } + } + persistLocked(profiles) + } + + override suspend fun markPreferredRecent(profileId: SavedPoolId, timestampMs: Long) = + recordSuccessfulConnection(profileId, timestampMs) + + override suspend fun updateLocalAddress( + profileId: SavedPoolId, + address: String, + port: Int, + gatewayLabel: String, + controllerIdentity: String? + ) = mutex.withLock { + val profiles = loadLocked().toMutableList() + val p = profiles.find { it.id == profileId } ?: return@withLock + val idx = p.routes.indexOfFirst { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL } + if (idx < 0) return@withLock + val existing = p.routes[idx] as SavedRoute.ScreenLogicLocal + // Conservative: if both identities are known and conflict, refuse the update. + val savedId = existing.controllerIdentity ?: p.controllerIdentity + if (savedId != null && controllerIdentity != null && savedId != controllerIdentity) { + return@withLock + } + p.routes[idx] = SavedRoute.ScreenLogicLocal( + address = address.trim(), + port = port, + gatewayLabel = gatewayLabel, + password = existing.password, + passwordRequired = existing.passwordRequired, + blankPasswordSucceeded = existing.blankPasswordSucceeded, + controllerIdentity = controllerIdentity ?: existing.controllerIdentity + ) + if (controllerIdentity != null) p.controllerIdentity = controllerIdentity + persistLocked(profiles) + } + + override suspend fun resetVault() = mutex.withLock { + store.delete() + try { + onKeyReset?.invoke() + } catch (_: Exception) { + } + cached = emptyList() + lastHealth = VaultHealth.EMPTY + } + + private fun needsPasswordPrompt(route: SavedRoute, password: String?): Boolean { + if (!password.isNullOrEmpty()) return false + return when (route) { + is SavedRoute.ScreenLogicRemote -> + route.passwordRequired && !route.blankPasswordSucceeded + is SavedRoute.ScreenLogicLocal -> + route.passwordRequired && !route.blankPasswordSucceeded + } + } + + private fun normalizeRoute(route: SavedRoute, rememberPassword: Boolean): SavedRoute { + return when (route) { + is SavedRoute.ScreenLogicRemote -> { + val pw = route.password + val blank = pw.isNullOrEmpty() + SavedRoute.ScreenLogicRemote( + systemCode = route.systemCode, + password = if (rememberPassword && !blank) pw else null, + passwordRequired = !blank && !rememberPassword, + blankPasswordSucceeded = blank + ) + } + is SavedRoute.ScreenLogicLocal -> { + val pw = route.password + val blank = pw.isNullOrEmpty() + SavedRoute.ScreenLogicLocal( + address = route.address, + port = route.port, + gatewayLabel = route.gatewayLabel, + password = if (rememberPassword && !blank) pw else null, + passwordRequired = !blank && !rememberPassword, + blankPasswordSucceeded = blank, + controllerIdentity = route.controllerIdentity + ) + } + } + } + + private fun mergeRoute(profile: SavedPoolProfile, route: SavedRoute) { + val idx = profile.routes.indexOfFirst { it.kind == route.kind } + if (idx >= 0) { + profile.routes[idx] = route + } else { + profile.routes += route + } + } + + private fun SavedRoute.ScreenLogicRemote.copyRoute( + password: String?, + passwordRequired: Boolean, + blankPasswordSucceeded: Boolean + ) = SavedRoute.ScreenLogicRemote( + systemCode = systemCode, + password = password, + passwordRequired = passwordRequired, + blankPasswordSucceeded = blankPasswordSucceeded + ) + + private fun SavedRoute.ScreenLogicLocal.copyRoute( + password: String?, + passwordRequired: Boolean, + blankPasswordSucceeded: Boolean + ) = SavedRoute.ScreenLogicLocal( + address = address, + port = port, + gatewayLabel = gatewayLabel, + password = password, + passwordRequired = passwordRequired, + blankPasswordSucceeded = blankPasswordSucceeded, + controllerIdentity = controllerIdentity + ) + + private fun loadLocked(): List { + cached?.let { return it } + if (!store.exists()) { + lastHealth = VaultHealth.EMPTY + cached = emptyList() + return emptyList() + } + val envelope = store.read() + ?: run { + lastHealth = VaultHealth.EMPTY + cached = emptyList() + return emptyList() + } + if (envelope.isEmpty()) { + lastHealth = VaultHealth.EMPTY + cached = emptyList() + return emptyList() + } + val plain = try { + cipher.decrypt(envelope) + } catch (e: VaultException) { + lastHealth = e.health + throw e + } catch (e: Exception) { + lastHealth = VaultHealth.AUTH_TAG_FAILURE + throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Decrypt failed", e) + } + val profiles = SavedPoolCodec.decode(plain) + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + cached = profiles + return profiles + } + + private fun persistLocked(profiles: List) { + val plain = SavedPoolCodec.encode(profiles) + val envelope = cipher.encrypt(plain) + store.writeAtomic(envelope) + cached = profiles.toList() + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + } +} + +fun SavedPoolProfile.toSummary(): SavedPoolSummary { + val preferred = routes.find { it.kind == preferredRoute } ?: routes.firstOrNull() + val kindLabel = when (preferred?.kind) { + SavedRouteKind.SCREENLOGIC_REMOTE -> "Remote" + SavedRouteKind.SCREENLOGIC_LOCAL -> "Home Wi‑Fi" + null -> "Saved" + } + val passwordRequired = passwordRequiredForConnect() + return SavedPoolSummary( + id = id, + displayName = displayName, + adapterId = adapterId, + routeKindLabel = kindLabel, + lastSuccessAtMs = lastSuccessAtMs, + passwordRequired = passwordRequired, + readyToConnect = readyToConnect(), + hasLocalRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL }, + hasRemoteRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE } + ) +} + +/** + * Helpers to build drafts from successful ScreenLogic connections. + * + * Password is kept on the draft route until [DefaultSavedPoolRepository] normalizes it + * via [ConnectSaveOptions]-equivalent [SavedPoolDraft.rememberPassword]. That way a + * non-blank password with remember=false still records passwordRequired correctly. + */ +object SavedPoolDraftFactory { + fun fromRemote( + systemCode: String, + password: String, + displayName: String, + controllerIdentity: String?, + savePassword: Boolean, + timestampMs: Long = System.currentTimeMillis() + ): SavedPoolDraft { + val blank = password.isEmpty() + return SavedPoolDraft( + displayName = displayName.ifBlank { "Remote pool" }, + adapterId = ScreenLogicIds.ADAPTER_ID, + controllerIdentity = controllerIdentity, + route = SavedRoute.ScreenLogicRemote( + systemCode = systemCode.trim(), + // Keep password for normalizeRoute; repository strips when not remembered. + password = if (blank) null else password, + passwordRequired = !blank && !savePassword, + blankPasswordSucceeded = blank + ), + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + rememberPassword = savePassword, + timestampMs = timestampMs + ) + } + + fun fromLocal( + gateway: DiscoveredGateway, + password: String, + controllerIdentity: String?, + savePassword: Boolean, + displayName: String? = null, + timestampMs: Long = System.currentTimeMillis() + ): SavedPoolDraft { + val blank = password.isEmpty() + return SavedPoolDraft( + displayName = displayName ?: gateway.displayName.ifBlank { "Home pool" }, + adapterId = ScreenLogicIds.ADAPTER_ID, + controllerIdentity = controllerIdentity, + route = SavedRoute.ScreenLogicLocal( + address = gateway.address.trim(), + port = gateway.port, + gatewayLabel = gateway.displayName, + password = if (blank) null else password, + passwordRequired = !blank && !savePassword, + blankPasswordSucceeded = blank, + controllerIdentity = controllerIdentity + ), + preferredRoute = SavedRouteKind.SCREENLOGIC_LOCAL, + rememberPassword = savePassword, + timestampMs = timestampMs + ) + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt new file mode 100644 index 0000000..845e444 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt @@ -0,0 +1,52 @@ +package com.pentaircontrol.pool.savedpools + +import android.content.Context +import com.pentaircontrol.pool.settings.AppPreferencesStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Production wiring for the encrypted saved-pools vault. + * + * Vault path: [Context.getNoBackupFilesDir] / [AtomicVaultFileStore.VAULT_FILE_NAME] + * Cipher: Android Keystore AES-256-GCM, alias [AndroidKeystoreVaultCipher.KEY_ALIAS] + */ +object SavedPoolRepositoryFactory { + + fun create( + context: Context, + preferences: AppPreferencesStore + ): ProductionSavedPools { + val appContext = context.applicationContext + val cipher = AndroidKeystoreVaultCipher() + val store = AtomicVaultFileStore(appContext.noBackupFilesDir) + val repository = DefaultSavedPoolRepository( + cipher = cipher, + store = store, + preferences = preferences, + onKeyReset = { cipher.deleteKey() } + ) + return ProductionSavedPools( + repository = repository, + cipher = cipher, + store = store + ) + } + + /** Suspend helpers that force IO dispatcher for Keystore / disk. */ + suspend fun onIo(block: suspend () -> T): T = + withContext(Dispatchers.IO) { block() } +} + +class ProductionSavedPools( + val repository: SavedPoolRepository, + val cipher: AndroidKeystoreVaultCipher, + val store: AtomicVaultFileStore +) { + fun diagnosticsVaultPathHint(): String = + "noBackupFilesDir/${AtomicVaultFileStore.VAULT_FILE_NAME}" + + fun diagnosticsKeyAlias(): String = AndroidKeystoreVaultCipher.KEY_ALIAS + + fun diagnosticsSecurityLevel(): String = cipher.securityLevelLabel() +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt new file mode 100644 index 0000000..7723f41 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt @@ -0,0 +1,101 @@ +package com.pentaircontrol.pool.savedpools + +import java.nio.ByteBuffer +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Encrypts/decrypts the saved-pools vault payload. + * Implementations must use AES-GCM with a random IV per write and never reuse IVs. + */ +interface VaultCipher { + fun encrypt(plaintext: ByteArray): ByteArray + fun decrypt(envelope: ByteArray): ByteArray +} + +/** + * Versioned AES-GCM envelope: + * + * ``` + * magic[4] = "WCAE" + * version u8 = 1 + * ivLen u8 + * iv[ivLen] + * ciphertext+tag[...] + * ``` + * + * AAD: [AAD_STRING] UTF-8 bytes. + */ +object AesGcmVaultEnvelope { + const val MAGIC = "WCAE" + const val VERSION: Int = 1 + const val IV_LENGTH: Int = 12 + const val TAG_BITS: Int = 128 + const val AAD_STRING: String = "WestCurrentSavedPools:v1" + private const val MAX_ENVELOPE = 600_000 + + fun encrypt(key: SecretKey, plaintext: ByteArray, random: SecureRandom = SecureRandom()): ByteArray { + val iv = ByteArray(IV_LENGTH) + random.nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(plaintext) + val out = ByteBuffer.allocate(4 + 1 + 1 + iv.size + ciphertext.size) + out.put(MAGIC.toByteArray(Charsets.US_ASCII)) + out.put(VERSION.toByte()) + out.put(iv.size.toByte()) + out.put(iv) + out.put(ciphertext) + return out.array() + } + + fun decrypt(key: SecretKey, envelope: ByteArray): ByteArray { + if (envelope.size > MAX_ENVELOPE) { + throw VaultException(VaultHealth.CORRUPT, "Envelope too large") + } + if (envelope.size < 4 + 1 + 1 + IV_LENGTH + 16) { + throw VaultException(VaultHealth.CORRUPT, "Envelope truncated") + } + val buf = ByteBuffer.wrap(envelope) + val magicBytes = ByteArray(4) + buf.get(magicBytes) + if (String(magicBytes, Charsets.US_ASCII) != MAGIC) { + throw VaultException(VaultHealth.CORRUPT, "Bad envelope magic") + } + val version = buf.get().toInt() and 0xFF + if (version != VERSION) { + throw VaultException(VaultHealth.UNSUPPORTED_SCHEMA, "Unsupported envelope version") + } + val ivLen = buf.get().toInt() and 0xFF + if (ivLen != IV_LENGTH || buf.remaining() < ivLen + 16) { + throw VaultException(VaultHealth.CORRUPT, "Invalid IV length") + } + val iv = ByteArray(ivLen) + buf.get(iv) + val ciphertext = ByteArray(buf.remaining()) + buf.get(ciphertext) + return try { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) + cipher.doFinal(ciphertext) + } catch (e: Exception) { + throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + } + } +} + +/** Test/production cipher backed by an injected [SecretKey] (never hard-coded material). */ +class SecretKeyVaultCipher( + private val key: SecretKey, + private val random: SecureRandom = SecureRandom() +) : VaultCipher { + override fun encrypt(plaintext: ByteArray): ByteArray = + AesGcmVaultEnvelope.encrypt(key, plaintext, random) + + override fun decrypt(envelope: ByteArray): ByteArray = + AesGcmVaultEnvelope.decrypt(key, envelope) +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt new file mode 100644 index 0000000..6b6174d --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt @@ -0,0 +1,144 @@ +package com.pentaircontrol.pool.savedpools + +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap + +/** Atomic encrypted blob store. */ +interface VaultFileStore { + fun read(): ByteArray? + fun writeAtomic(envelope: ByteArray) + fun delete() + fun exists(): Boolean +} + +/** + * In-memory store for unit tests. + */ +class InMemoryVaultFileStore : VaultFileStore { + @Volatile + private var bytes: ByteArray? = null + + override fun read(): ByteArray? = bytes?.copyOf() + + override fun writeAtomic(envelope: ByteArray) { + bytes = envelope.copyOf() + } + + override fun delete() { + bytes = null + } + + override fun exists(): Boolean = bytes != null +} + +/** + * Atomic file store under a provided directory (production: [android.content.Context.getNoBackupFilesDir]). + * + * Write pattern: encrypt already done by caller → write temp → flush/sync → rename over prior file. + * A crash leaves either the prior valid vault or the new valid vault, not a half-written chimera. + */ +class AtomicVaultFileStore( + private val directory: File, + private val fileName: String = VAULT_FILE_NAME +) : VaultFileStore { + private val file = File(directory, fileName) + private val temp = File(directory, "$fileName.tmp") + private val backup = File(directory, "$fileName.bak") + + override fun read(): ByteArray? { + if (!file.exists()) return null + return try { + file.readBytes() + } catch (e: IOException) { + throw VaultException(VaultHealth.IO_FAILURE, "Failed to read vault", e) + } + } + + override fun writeAtomic(envelope: ByteArray) { + try { + if (!directory.exists() && !directory.mkdirs()) { + throw IOException("Cannot create vault directory") + } + // Remove stale temp/backup from a previous crash. + if (temp.exists()) temp.delete() + if (backup.exists()) backup.delete() + + FileOutputStream(temp).use { fos -> + fos.write(envelope) + fos.flush() + try { + fos.fd.sync() + } catch (_: Exception) { + // Best-effort fsync; still proceed with rename. + } + } + + if (file.exists()) { + if (!file.renameTo(backup)) { + // Windows / some Android paths: delete destination then rename. + if (!file.delete() || !temp.renameTo(file)) { + // Last resort copy + file.writeBytes(temp.readBytes()) + temp.delete() + } else { + backup.delete() + return + } + } else { + if (!temp.renameTo(file)) { + // Restore prior vault + backup.renameTo(file) + throw IOException("Atomic rename failed") + } + backup.delete() + } + } else { + if (!temp.renameTo(file)) { + file.writeBytes(temp.readBytes()) + temp.delete() + } + } + if (temp.exists()) temp.delete() + if (backup.exists()) backup.delete() + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + try { + temp.delete() + } catch (_: Exception) { + } + throw VaultException(VaultHealth.IO_FAILURE, "Failed to write vault", e) + } + } + + override fun delete() { + try { + if (file.exists()) file.delete() + if (temp.exists()) temp.delete() + if (backup.exists()) backup.delete() + } catch (e: Exception) { + throw VaultException(VaultHealth.IO_FAILURE, "Failed to delete vault", e) + } + } + + override fun exists(): Boolean = file.exists() + + /** Diagnostics only — path string, not secret contents. */ + fun filePathForDiagnostics(): String = file.absolutePath + + fun directoryForDiagnostics(): String = directory.absolutePath + + companion object { + const val VAULT_FILE_NAME = "saved_pools_v1.enc" + } +} + +/** Named in-memory vaults for concurrent repository tests. */ +object SharedInMemoryVaults { + private val map = ConcurrentHashMap() + fun named(name: String): InMemoryVaultFileStore = + map.getOrPut(name) { InMemoryVaultFileStore() } + fun clearAll() = map.clear() +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt index f9af91d..d7094cc 100644 --- a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt +++ b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt @@ -398,6 +398,13 @@ class SiteSession( fun canRememberTemperatureUnitForActiveController(): Boolean = activeControllerIdentity.get() != null + /** + * Controller-reported preference identity for the active connection, or null. + * Never derived from credentials. Safe for saved-pool merge matching only — + * not for encryption and not for UI display of secrets. + */ + fun activeControllerIdentity(): String? = activeControllerIdentity.get() + @VisibleForTesting fun activeControllerIdentityForTest(): String? = activeControllerIdentity.get() @@ -886,6 +893,13 @@ class SiteSession( @VisibleForTesting fun connectionGenerationForTest(): Int = connectionGeneration.get() + /** + * In-memory connection target for the active session only. + * Used to optionally save a pool after success. Never persisted to ordinary preferences. + * Callers must not put this into StateFlow or logs. + */ + fun activeConnectionTarget(): AdapterConnectionTarget? = activeTargetRef.get() + @VisibleForTesting fun activeConnectionTargetForTest(): AdapterConnectionTarget? = activeTargetRef.get() diff --git a/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt b/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt index d7c96da..353e9c5 100644 --- a/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt +++ b/app/src/main/java/com/pentaircontrol/pool/settings/AppPreferencesStore.kt @@ -6,12 +6,11 @@ package com.pentaircontrol.pool.settings * Values are stored in MODE_PRIVATE SharedPreferences on Android (or in-memory for tests). * That is process/user sandbox isolation, not encryption at rest. * - * Phase 1.2: system codes and passwords are **never** persisted. Only non-sensitive display / - * preference flags may live here. Connection targets and remote passwords remain in-process - * memory for the session / bounded reconnect lifetime only. + * System codes, passwords, controller addresses, and connection routes are **never** + * stored here. Only non-sensitive display / preference flags may live in ordinary prefs. * - * Follow-up (future security sprint): Keystore-backed protected storage if identifiers must - * be remembered. + * Phase 1.6 saved-pool credentials live in a separate AES-GCM vault under + * `noBackupFilesDir`, encrypted with an Android Keystore key — never in this store. */ interface AppPreferencesStore { fun getString(key: String): String? diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt index 05d0c6e..bd55e87 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt @@ -55,6 +55,10 @@ import com.pentaircontrol.pool.domain.LightTargetId import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.savedpools.ConnectSaveOptions +import com.pentaircontrol.pool.savedpools.SavedPoolId +import com.pentaircontrol.pool.savedpools.SavedPoolPasswordPrompt +import com.pentaircontrol.pool.savedpools.SavedPoolsListState import com.pentaircontrol.pool.session.SiteSession import com.pentaircontrol.pool.ui.components.StatusBanner import com.pentaircontrol.pool.ui.components.statusToneColor @@ -97,9 +101,9 @@ fun PoolApp( appearance: AppearanceMode, onAppearanceSelected: (AppearanceMode) -> Unit, onDiscover: () -> Unit, - onConnect: (DiscoveredGateway, String) -> Unit, - onManualConnect: (String, Int, String) -> Unit, - onRemoteConnect: (systemCode: String, password: String) -> Unit, + onConnect: (DiscoveredGateway, String, ConnectSaveOptions) -> Unit, + onManualConnect: (String, Int, String, ConnectSaveOptions) -> Unit, + onRemoteConnect: (systemCode: String, password: String, ConnectSaveOptions) -> Unit, onSimulator: () -> Unit, onDisconnect: () -> Unit, onToggleCircuit: (CircuitId, Boolean) -> Unit, @@ -114,7 +118,19 @@ fun PoolApp( canRememberTemperatureUnit: Boolean = true, canRetryConnection: Boolean = false, onRetryConnection: () -> Unit = {}, - onClearStatusMessage: (String?) -> Unit + onClearStatusMessage: (String?) -> Unit, + savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), + passwordPrompt: SavedPoolPasswordPrompt? = null, + onConnectSavedPool: (SavedPoolId) -> Unit = {}, + onSubmitSavedPassword: (SavedPoolId, String, Boolean) -> Unit = { _, _, _ -> }, + onDismissPasswordPrompt: () -> Unit = {}, + onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, + onDeleteSavedPool: (SavedPoolId) -> Unit = {}, + onSaveCurrentPool: () -> Unit = {}, + onMarkPreferredSavedPool: (SavedPoolId) -> Unit = {}, + onRetryVault: () -> Unit = {}, + onResetVault: () -> Unit = {}, + vaultDiagnostics: String? = null ) { val showMainShell = connection.isLiveConnected || connection is ConnectionStatus.StaleOrDegraded || @@ -141,7 +157,16 @@ fun PoolApp( onConnect = onConnect, onManualConnect = onManualConnect, onRemoteConnect = onRemoteConnect, - onSimulator = onSimulator + onSimulator = onSimulator, + savedPoolsState = savedPoolsState, + passwordPrompt = passwordPrompt, + onConnectSavedPool = onConnectSavedPool, + onSubmitSavedPassword = onSubmitSavedPassword, + onDismissPasswordPrompt = onDismissPasswordPrompt, + onRenameSavedPool = onRenameSavedPool, + onDeleteSavedPool = onDeleteSavedPool, + onRetryVault = onRetryVault, + onResetVault = onResetVault ) return } @@ -310,7 +335,14 @@ fun PoolApp( onForgetSavedUnit = onForgetSavedTemperatureUnit, savedUnit = savedTemperatureUnit, canRememberUnit = canRememberTemperatureUnit, - receipts = commandReceipts + receipts = commandReceipts, + savedPoolsState = savedPoolsState, + onRenameSavedPool = onRenameSavedPool, + onDeleteSavedPool = onDeleteSavedPool, + onSaveCurrentPool = onSaveCurrentPool, + onMarkPreferred = onMarkPreferredSavedPool, + onResetVault = onResetVault, + vaultDiagnostics = vaultDiagnostics ) } } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt index 6d907e3..326c2b8 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt @@ -4,6 +4,9 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicAdapter +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget import com.pentaircontrol.pool.domain.BodyId import com.pentaircontrol.pool.domain.CommandReceipt import com.pentaircontrol.pool.domain.CircuitId @@ -15,6 +18,18 @@ import com.pentaircontrol.pool.domain.LightTargetId import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.savedpools.ConnectSaveOptions +import com.pentaircontrol.pool.savedpools.SavedPoolConnectionResult +import com.pentaircontrol.pool.savedpools.SavedPoolDraftFactory +import com.pentaircontrol.pool.savedpools.SavedPoolId +import com.pentaircontrol.pool.savedpools.SavedPoolPasswordPrompt +import com.pentaircontrol.pool.savedpools.SavedPoolRepository +import com.pentaircontrol.pool.savedpools.SavedPoolRepositoryFactory +import com.pentaircontrol.pool.savedpools.SavedPoolSummary +import com.pentaircontrol.pool.savedpools.SavedPoolsListState +import com.pentaircontrol.pool.savedpools.SavedRouteKind +import com.pentaircontrol.pool.savedpools.VaultException +import com.pentaircontrol.pool.savedpools.VaultHealth import com.pentaircontrol.pool.session.SiteSession import com.pentaircontrol.pool.settings.AndroidAppPreferencesStore import com.pentaircontrol.pool.settings.AppPreferencesStore @@ -24,23 +39,27 @@ import com.pentaircontrol.pool.ui.presentation.friendlyRejection import com.pentaircontrol.pool.ui.presentation.receiptSummary import com.pentaircontrol.pool.ui.theme.AppearanceMode import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext /** * UI ViewModel — domain types only. No protocol models or packet codes. * - * [sessionFactory] receives [viewModelScope] so all session work is canceled when the - * ViewModel is cleared. [onCleared] also calls [SiteSession.close] for adapter teardown - * (disconnect runs under NonCancellable after scope cancellation). + * Saved-pool secrets are never held in [StateFlow] or Compose state. + * Decrypted connection material exists only long enough to begin [SiteSession] connect. * - * Do not use GlobalScope or an application-lifetime free-floating scope. + * [sessionFactory] receives [viewModelScope] so all session work is canceled when the + * ViewModel is cleared. [onCleared] also calls [SiteSession.close] for adapter teardown. */ class PoolViewModel( private val preferences: AppPreferencesStore = InMemoryAppPreferencesStore(), + private val savedPools: SavedPoolRepository? = null, sessionFactory: (CoroutineScope) -> SiteSession ) : ViewModel() { @@ -79,50 +98,336 @@ class PoolViewModel( val canRetryConnection: StateFlow = session.canRetryConnection .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - /** True when unit confirmations can be remembered (controller reported an identity). */ val canRememberTemperatureUnit: StateFlow = session.canRememberTemperatureUnit .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - /** Manually saved temperature fallback for the active controller, or null. */ val savedTemperatureUnit: StateFlow = session.savedTemperatureUnit .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null) private val _statusMessage = MutableStateFlow(null) val statusMessage: StateFlow = _statusMessage + // ——— Saved pools (non-secret UI state only) ——— + + private val _savedPoolsState = + MutableStateFlow(SavedPoolsListState.Loading) + val savedPoolsState: StateFlow = _savedPoolsState.asStateFlow() + + private val _passwordPrompt = MutableStateFlow(null) + val passwordPrompt: StateFlow = _passwordPrompt.asStateFlow() + + /** Profile id of the currently connected saved pool, if any (non-secret). */ + private val _activeSavedProfileId = MutableStateFlow(null) + val activeSavedProfileId: StateFlow = _activeSavedProfileId.asStateFlow() + + /** Pending password-remember consent for a password-prompt reconnect (not a secret). */ + private var pendingRememberPassword: Boolean = false + private var pendingPasswordProfileId: SavedPoolId? = null + private var pendingPasswordRouteKind: SavedRouteKind? = null + + init { + refreshSavedPools() + } + /** Test hook: inspect the bound session. */ internal fun sessionForTest(): SiteSession = session + internal fun savedPoolsForTest(): SavedPoolRepository? = savedPools + + fun refreshSavedPools() { + val repo = savedPools + if (repo == null) { + _savedPoolsState.value = SavedPoolsListState.Available(emptyList()) + return + } + viewModelScope.launch { + try { + val list = withContext(Dispatchers.IO) { repo.list() } + _savedPoolsState.value = SavedPoolsListState.Available(list) + } catch (e: VaultException) { + if (e.health == VaultHealth.EMPTY) { + _savedPoolsState.value = SavedPoolsListState.Available(emptyList()) + } else { + _savedPoolsState.value = SavedPoolsListState.Unreadable(e.health) + } + } catch (_: Exception) { + _savedPoolsState.value = + SavedPoolsListState.Unreadable(VaultHealth.LOCKED_OR_UNAVAILABLE) + } + } + } + fun discover() { viewModelScope.launch { session.discoverLocal() } } - fun connect(gateway: DiscoveredGateway, password: String = "") { - viewModelScope.launch { session.connectLocal(gateway, password) } + fun connect( + gateway: DiscoveredGateway, + password: String = "", + save: ConnectSaveOptions = ConnectSaveOptions() + ) { + viewModelScope.launch { + session.connectLocal(gateway, password) + maybeSaveLocal(gateway, password, save) + } } - fun connectManual(ip: String, port: Int, password: String) { - viewModelScope.launch { session.connectManual(ip, port, password) } + fun connectManual( + ip: String, + port: Int, + password: String, + save: ConnectSaveOptions = ConnectSaveOptions() + ) { + val gw = DiscoveredGateway( + id = "$ip:$port", + displayName = "Manual $ip", + address = ip.trim(), + port = port + ) + connect(gw, password, save) } - fun connectRemote(systemCode: String, password: String) { - viewModelScope.launch { session.connectRemote(systemCode, password) } + fun connectRemote( + systemCode: String, + password: String, + save: ConnectSaveOptions = ConnectSaveOptions() + ) { + viewModelScope.launch { + session.connectRemote(systemCode, password) + if (!session.connection.value.isLiveConnected) return@launch + if (!save.savePool || savedPools == null) return@launch + try { + val identity = session.activeControllerIdentity() + val draft = SavedPoolDraftFactory.fromRemote( + systemCode = systemCode, + password = password, + displayName = "Remote pool", + controllerIdentity = identity, + savePassword = save.rememberPassword + ) + withContext(Dispatchers.IO) { + savedPools.saveAfterSuccessfulConnection(draft) + } + refreshSavedPools() + } catch (_: Exception) { + _statusMessage.value = + "Connected, but the pool could not be saved securely on this phone." + } + } } fun connectSimulator() { viewModelScope.launch { session.connectSimulator() } } + /** + * One-tap reconnect from a saved profile summary. + * Decrypts on IO; never puts secrets into StateFlow. + * If password is required and not stored, shows [passwordPrompt] only. + */ + fun connectSavedPool(profileId: SavedPoolId) { + val repo = savedPools ?: return + viewModelScope.launch { + _passwordPrompt.value = null + val result = try { + withContext(Dispatchers.IO) { + repo.loadConnection(profileId) + } + } catch (e: VaultException) { + _statusMessage.value = vaultFailureMessage(e.health) + return@launch + } + when (result) { + is SavedPoolConnectionResult.PasswordRequired -> { + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = result.profileId, + displayName = result.displayName, + routeKind = result.routeKind + ) + } + is SavedPoolConnectionResult.Ready -> { + completeSavedConnect(repo, result, passwordOverride = null, rememberPassword = false) + } + is SavedPoolConnectionResult.VaultUnavailable -> { + _savedPoolsState.value = SavedPoolsListState.Unreadable(result.health) + _statusMessage.value = vaultFailureMessage(result.health) + } + is SavedPoolConnectionResult.NotFound -> { + _statusMessage.value = "That saved pool is no longer available." + refreshSavedPools() + } + is SavedPoolConnectionResult.Failure -> { + _statusMessage.value = result.message + } + is SavedPoolConnectionResult.IdentityMismatch -> { + _statusMessage.value = result.message + } + } + } + } + + /** + * Complete a password-required reconnect. Password is not retained in UI state. + */ + fun submitSavedPoolPassword( + profileId: SavedPoolId, + password: String, + rememberPassword: Boolean = false + ) { + val repo = savedPools ?: return + viewModelScope.launch { + _passwordPrompt.value = null + pendingRememberPassword = rememberPassword + pendingPasswordProfileId = profileId + val result = try { + withContext(Dispatchers.IO) { + repo.loadConnection(profileId, passwordOverride = password) + } + } catch (e: VaultException) { + _statusMessage.value = vaultFailureMessage(e.health) + return@launch + } + when (result) { + is SavedPoolConnectionResult.Ready -> { + pendingPasswordRouteKind = null + completeSavedConnect(repo, result, password, rememberPassword) + } + is SavedPoolConnectionResult.PasswordRequired -> { + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = result.profileId, + displayName = result.displayName, + routeKind = result.routeKind + ) + } + is SavedPoolConnectionResult.VaultUnavailable -> { + _statusMessage.value = vaultFailureMessage(result.health) + } + else -> { + _statusMessage.value = "Couldn't prepare that saved pool." + } + } + } + } + + fun dismissPasswordPrompt() { + _passwordPrompt.value = null + pendingRememberPassword = false + pendingPasswordProfileId = null + pendingPasswordRouteKind = null + } + + fun renameSavedPool(profileId: SavedPoolId, displayName: String) { + val repo = savedPools ?: return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { repo.rename(profileId, displayName) } + refreshSavedPools() + _statusMessage.value = "Saved pool renamed" + } catch (_: Exception) { + _statusMessage.value = "Couldn't rename that saved pool." + } + } + } + + fun deleteSavedPool(profileId: SavedPoolId) { + val repo = savedPools ?: return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { repo.delete(profileId) } + if (_activeSavedProfileId.value == profileId) { + _activeSavedProfileId.value = null + _statusMessage.value = + "Saved pool removed. The current connection can continue until you disconnect." + } else { + _statusMessage.value = "Saved pool removed" + } + refreshSavedPools() + } catch (_: Exception) { + _statusMessage.value = "Couldn't remove that saved pool." + } + } + } + + fun markPreferredSavedPool(profileId: SavedPoolId) { + val repo = savedPools ?: return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { repo.markPreferredRecent(profileId) } + refreshSavedPools() + } catch (_: Exception) { + } + } + } + + /** + * Save the currently connected pool if it was not saved at connect time. + * Uses the in-memory session target only (never ordinary preferences). + */ + fun saveCurrentConnectedPool(rememberPassword: Boolean = false) { + val repo = savedPools ?: return + viewModelScope.launch { + if (!session.connection.value.isLiveConnected) { + _statusMessage.value = "Connect first, then save this pool." + return@launch + } + val target = session.activeConnectionTarget() + val identity = session.activeControllerIdentity() + try { + val draft = when (target) { + is ScreenLogicRemoteTarget -> SavedPoolDraftFactory.fromRemote( + systemCode = target.systemCodeForConnect(), + password = target.password, + displayName = "Remote pool", + controllerIdentity = identity, + savePassword = rememberPassword + ) + is ScreenLogicLocalTarget -> SavedPoolDraftFactory.fromLocal( + gateway = target.gateway, + password = target.password, + controllerIdentity = identity, + savePassword = rememberPassword + ) + else -> { + _statusMessage.value = "This connection type can't be saved yet." + return@launch + } + } + withContext(Dispatchers.IO) { repo.saveAfterSuccessfulConnection(draft) } + refreshSavedPools() + _statusMessage.value = "Pool saved securely on this phone" + } catch (_: Exception) { + _statusMessage.value = "Couldn't save this pool securely." + } + } + } + + fun resetSavedPoolsVault() { + val repo = savedPools ?: return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { repo.resetVault() } + _activeSavedProfileId.value = null + _passwordPrompt.value = null + _savedPoolsState.value = SavedPoolsListState.Available(emptyList()) + _statusMessage.value = "Saved pools were reset on this phone" + } catch (_: Exception) { + _statusMessage.value = "Couldn't reset saved pools." + } + } + } + fun disconnect() { - viewModelScope.launch { session.disconnect() } + viewModelScope.launch { + session.disconnect() + _activeSavedProfileId.value = null + dismissPasswordPrompt() + } } - /** Explicit user retry after automatic reconnect exhaustion (same in-memory target). */ fun retryConnection() { viewModelScope.launch { val ok = session.retryConnection() if (!ok && session.lastError.value != null) { - // Raw error text stays in lastError for Diagnostics. _statusMessage.value = RETRY_FAILED_MESSAGE } } @@ -135,8 +440,6 @@ class PoolViewModel( TemperatureUnit.CELSIUS -> "°C" TemperatureUnit.UNKNOWN -> "unknown" } - // Honest: never announce success for a rejected confirmation, and only - // claim "remembered" when the controller reported an identity. _statusMessage.value = when { !applied -> "Your pool controller already reports its temperature scale — nothing changed" @@ -147,7 +450,6 @@ class PoolViewModel( } } - /** Forget saved temperature scale for the connected controller only. */ fun forgetSavedTemperatureUnit() { session.forgetSavedTemperatureUnit() _statusMessage.value = "Saved temperature scale forgotten for this controller" @@ -198,6 +500,113 @@ class PoolViewModel( } } + private suspend fun maybeSaveLocal( + gateway: DiscoveredGateway, + password: String, + save: ConnectSaveOptions + ) { + if (!session.connection.value.isLiveConnected) return + if (!save.savePool || savedPools == null) return + try { + val identity = session.activeControllerIdentity() + val draft = SavedPoolDraftFactory.fromLocal( + gateway = gateway, + password = password, + controllerIdentity = identity, + savePassword = save.rememberPassword + ) + withContext(Dispatchers.IO) { + savedPools.saveAfterSuccessfulConnection(draft) + } + refreshSavedPools() + } catch (_: Exception) { + _statusMessage.value = + "Connected, but the pool could not be saved securely on this phone." + } + } + + private suspend fun completeSavedConnect( + repo: SavedPoolRepository, + ready: SavedPoolConnectionResult.Ready, + passwordOverride: String?, + rememberPassword: Boolean + ) { + val expectedIdentity = withContext(Dispatchers.IO) { + repo.expectedControllerIdentity(ready.profileId) + } + // Use the same ScreenLogic factory path as ordinary connects. + session.connectWithFactory(ready.connectionTarget) { ScreenLogicAdapter() } + + if (!session.connection.value.isLiveConnected) { + // Failed reconnect: keep profile, keep last-success timestamp. + _statusMessage.value = + "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." + return + } + + // Identity mismatch: reject install by disconnecting the candidate. + val actualIdentity = session.activeControllerIdentity() + if (expectedIdentity != null && actualIdentity != null && + expectedIdentity != actualIdentity + ) { + session.disconnect() + _statusMessage.value = + "That device doesn't match the saved pool. Connection was not kept." + return + } + + _activeSavedProfileId.value = ready.profileId + try { + withContext(Dispatchers.IO) { + repo.recordSuccessfulConnection(ready.profileId, System.currentTimeMillis()) + // Update local address if we connected via a local target (DHCP recovery path). + val target = ready.connectionTarget + if (target is ScreenLogicLocalTarget) { + repo.updateLocalAddress( + profileId = ready.profileId, + address = target.gateway.address, + port = target.gateway.port, + gatewayLabel = target.gateway.displayName, + controllerIdentity = actualIdentity ?: expectedIdentity + ) + } + if (rememberPassword && passwordOverride != null) { + val kind = when (target) { + is ScreenLogicRemoteTarget -> SavedRouteKind.SCREENLOGIC_REMOTE + is ScreenLogicLocalTarget -> SavedRouteKind.SCREENLOGIC_LOCAL + else -> null + } + if (kind != null) { + repo.updatePasswordAfterSuccess( + profileId = ready.profileId, + kind = kind, + password = passwordOverride, + rememberPassword = true + ) + } + } + } + refreshSavedPools() + } catch (_: Exception) { + // Connection succeeded; vault update is best-effort. + } + pendingRememberPassword = false + pendingPasswordProfileId = null + } + + private fun vaultFailureMessage(health: VaultHealth): String = when (health) { + VaultHealth.EMPTY -> "No saved pools on this phone." + VaultHealth.KEYSTORE_MISSING, + VaultHealth.KEY_INVALIDATED, + VaultHealth.AUTH_TAG_FAILURE, + VaultHealth.CORRUPT, + VaultHealth.UNSUPPORTED_SCHEMA, + VaultHealth.IO_FAILURE, + VaultHealth.LOCKED_OR_UNAVAILABLE -> + "Saved pools could not be opened on this device." + VaultHealth.AVAILABLE -> "Saved pools are available." + } + private fun publishResult(result: com.pentaircontrol.pool.domain.CommandOrchestrator.SubmitResult) { when { result.rejectedReason != null -> @@ -227,7 +636,6 @@ class PoolViewModel( super.onCleared() } - /** Test-only: invoke the same cleanup path as [onCleared]. */ @androidx.annotation.VisibleForTesting fun clearForTest() { onCleared() @@ -240,9 +648,11 @@ class PoolViewModelFactory( @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { val settings: AppPreferencesStore = AndroidAppPreferencesStore(application) + val production = SavedPoolRepositoryFactory.create(application, settings) return PoolViewModel( - sessionFactory = { scope -> SiteSession(scope = scope, settings = settings) }, - preferences = settings + preferences = settings, + savedPools = production.repository, + sessionFactory = { scope -> SiteSession(scope = scope, settings = settings) } ) as T } } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/preview/ScreenPreviews.kt b/app/src/main/java/com/pentaircontrol/pool/ui/preview/ScreenPreviews.kt index a34e3fd..e4d2949 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/preview/ScreenPreviews.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/preview/ScreenPreviews.kt @@ -166,9 +166,9 @@ private fun ConnectDarkPreview() { appearance = AppearanceMode.DARK, onAppearanceSelected = {}, onDiscover = {}, - onConnect = { _, _ -> }, - onManualConnect = { _, _, _ -> }, - onRemoteConnect = { _, _ -> }, + onConnect = { _, _, _ -> }, + onManualConnect = { _, _, _, _ -> }, + onRemoteConnect = { _, _, _ -> }, onSimulator = {} ) } @@ -184,9 +184,9 @@ private fun ConnectLightPreview() { appearance = AppearanceMode.LIGHT, onAppearanceSelected = {}, onDiscover = {}, - onConnect = { _, _ -> }, - onManualConnect = { _, _, _ -> }, - onRemoteConnect = { _, _ -> }, + onConnect = { _, _, _ -> }, + onManualConnect = { _, _, _, _ -> }, + onRemoteConnect = { _, _, _ -> }, onSimulator = {} ) } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt index 6f4f7c9..76b2ebf 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -23,6 +24,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.outlined.Cloud import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.ErrorOutline @@ -34,7 +36,10 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -59,17 +64,25 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.foundation.selection.selectable import com.pentaircontrol.pool.domain.ConnectionStatus import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.savedpools.ConnectSaveOptions +import com.pentaircontrol.pool.savedpools.SavedPoolId +import com.pentaircontrol.pool.savedpools.SavedPoolPasswordPrompt +import com.pentaircontrol.pool.savedpools.SavedPoolSummary +import com.pentaircontrol.pool.savedpools.SavedPoolsListState import com.pentaircontrol.pool.ui.theme.AppearanceMode import com.pentaircontrol.pool.ui.theme.WestCurrentTheme +import java.text.DateFormat +import java.util.Date private enum class ConnectChoice { FIND, REMOTE, MANUAL, DEMO } /** * Welcome + connection experience. Four plain-language choices; no protocol * vocabulary outside the advanced/manual section. + * + * Recent pools (Phase 1.6) appear above manual options when the vault has profiles. */ @Composable fun ConnectScreen( @@ -78,14 +91,27 @@ fun ConnectScreen( appearance: AppearanceMode, onAppearanceSelected: (AppearanceMode) -> Unit, onDiscover: () -> Unit, - onConnect: (DiscoveredGateway, String) -> Unit, - onManualConnect: (String, Int, String) -> Unit, - onRemoteConnect: (systemCode: String, password: String) -> Unit, - onSimulator: () -> Unit + onConnect: (DiscoveredGateway, String, ConnectSaveOptions) -> Unit, + onManualConnect: (String, Int, String, ConnectSaveOptions) -> Unit, + onRemoteConnect: (systemCode: String, password: String, ConnectSaveOptions) -> Unit, + onSimulator: () -> Unit, + savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), + passwordPrompt: SavedPoolPasswordPrompt? = null, + onConnectSavedPool: (SavedPoolId) -> Unit = {}, + onSubmitSavedPassword: (SavedPoolId, String, Boolean) -> Unit = { _, _, _ -> }, + onDismissPasswordPrompt: () -> Unit = {}, + onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, + onDeleteSavedPool: (SavedPoolId) -> Unit = {}, + onRetryVault: () -> Unit = {}, + onResetVault: () -> Unit = {} ) { var expanded by rememberSaveable { mutableStateOf(null) } var showAppearanceDialog by remember { mutableStateOf(false) } - val busy = connection is ConnectionStatus.Connecting + var renameTarget by remember { mutableStateOf(null) } + var removeTarget by remember { mutableStateOf(null) } + var showResetConfirm by remember { mutableStateOf(false) } + val busy = connection is ConnectionStatus.Connecting || + connection is ConnectionStatus.Reconnecting Box(modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { Column( @@ -157,6 +183,29 @@ fun ConnectScreen( } } + when (val pools = savedPoolsState) { + is SavedPoolsListState.Loading -> Unit + is SavedPoolsListState.Available -> { + if (pools.pools.isNotEmpty()) { + RecentPoolsSection( + pools = pools.pools, + busy = busy, + onConnect = onConnectSavedPool, + onRename = { renameTarget = it }, + onRemove = { removeTarget = it } + ) + Spacer(Modifier.height(8.dp)) + } + } + is SavedPoolsListState.Unreadable -> { + VaultUnreadableCard( + onRetry = onRetryVault, + onReset = { showResetConfirm = true } + ) + Spacer(Modifier.height(8.dp)) + } + } + ConnectOptionCard( icon = Icons.Outlined.Search, title = "Find my pool", @@ -227,13 +276,14 @@ fun ConnectScreen( ) is ConnectionStatus.AuthenticationRequired -> ConnectProblemCard( "Sign-in needed", - "The remote password wasn't accepted. Check it and try again." + "The password wasn't accepted. Check it and try again." ) else -> Unit } Text( - "System codes and passwords are never saved on this phone.", + "You can optionally save a pool on this phone after a successful connection. " + + "Saved connection details are encrypted on this device only.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, @@ -252,6 +302,293 @@ fun ConnectScreen( onDismiss = { showAppearanceDialog = false } ) } + + passwordPrompt?.let { prompt -> + SavedPasswordDialog( + displayName = prompt.displayName, + onSubmit = { password, remember -> + onSubmitSavedPassword(prompt.profileId, password, remember) + }, + onDismiss = onDismissPasswordPrompt + ) + } + + renameTarget?.let { target -> + RenamePoolDialog( + currentName = target.displayName, + onConfirm = { name -> + onRenameSavedPool(target.id, name) + renameTarget = null + }, + onDismiss = { renameTarget = null } + ) + } + + removeTarget?.let { target -> + AlertDialog( + onDismissRequest = { removeTarget = null }, + title = { Text("Remove saved pool?") }, + text = { + Text( + "“${target.displayName}” will be removed from this phone. " + + "You can save it again after a future connection." + ) + }, + confirmButton = { + TextButton(onClick = { + onDeleteSavedPool(target.id) + removeTarget = null + }) { Text("Remove") } + }, + dismissButton = { + TextButton(onClick = { removeTarget = null }) { Text("Cancel") } + } + ) + } + + if (showResetConfirm) { + AlertDialog( + onDismissRequest = { showResetConfirm = false }, + title = { Text("Reset saved pools?") }, + text = { + Text( + "This removes all saved pools and their encrypted connection details " + + "from this phone. Appearance and other settings are kept." + ) + }, + confirmButton = { + TextButton(onClick = { + onResetVault() + showResetConfirm = false + }) { Text("Reset saved pools") } + }, + dismissButton = { + TextButton(onClick = { showResetConfirm = false }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun RecentPoolsSection( + pools: List, + busy: Boolean, + onConnect: (SavedPoolId) -> Unit, + onRename: (SavedPoolSummary) -> Unit, + onRemove: (SavedPoolSummary) -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) { + Text( + "Recent pools", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier + .align(Alignment.Start) + .padding(bottom = 8.dp) + ) + pools.forEach { pool -> + RecentPoolCard( + pool = pool, + busy = busy, + onConnect = { onConnect(pool.id) }, + onRename = { onRename(pool) }, + onRemove = { onRemove(pool) } + ) + } + } +} + +@Composable +private fun RecentPoolCard( + pool: SavedPoolSummary, + busy: Boolean, + onConnect: () -> Unit, + onRename: () -> Unit, + onRemove: () -> Unit +) { + var menuOpen by remember { mutableStateOf(false) } + Card( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 72.dp) + .padding(start = 14.dp, end = 4.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(pool.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + pool.routeKindLabel, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Last connected ${formatLastConnected(pool.lastSuccessAtMs)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + if (pool.passwordRequired) "Password required" else "Ready to connect", + style = MaterialTheme.typography.labelMedium, + color = if (pool.passwordRequired) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + WestCurrentTheme.status.connected + }, + modifier = Modifier.padding(top = 2.dp) + ) + } + TextButton( + onClick = onConnect, + enabled = !busy, + modifier = Modifier.defaultMinSize(minHeight = 48.dp) + ) { + Text("Connect", fontWeight = FontWeight.Bold) + } + Box { + IconButton(onClick = { menuOpen = true }, enabled = !busy) { + Icon(Icons.Filled.MoreVert, contentDescription = "More actions") + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text("Rename") }, + onClick = { + menuOpen = false + onRename() + } + ) + DropdownMenuItem( + text = { Text("Remove") }, + onClick = { + menuOpen = false + onRemove() + } + ) + } + } + } + } +} + +@Composable +private fun VaultUnreadableCard(onRetry: () -> Unit, onReset: () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + shape = RoundedCornerShape(14.dp) + ) { + Column(Modifier.padding(14.dp)) { + Text( + "Saved pools could not be opened on this device.", + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + "You can retry, or reset saved pools on this phone. " + + "Reset does not change appearance or other settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + TextButton(onClick = onRetry) { Text("Retry") } + TextButton(onClick = onReset) { Text("Reset saved pools") } + } + } + } +} + +@Composable +private fun SavedPasswordDialog( + displayName: String, + onSubmit: (password: String, remember: Boolean) -> Unit, + onDismiss: () -> Unit +) { + var password by remember { mutableStateOf("") } + var remember by remember { mutableStateOf(false) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Password for $displayName") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Enter the pool password. The system code and address are already saved.", + style = MaterialTheme.typography.bodySmall + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { remember = !remember } + .defaultMinSize(minHeight = 48.dp) + ) { + Checkbox(checked = remember, onCheckedChange = { remember = it }) + Text( + "Remember password securely", + style = MaterialTheme.typography.bodyMedium + ) + } + } + }, + confirmButton = { + TextButton(onClick = { onSubmit(password, remember) }) { + Text("Connect") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun RenamePoolDialog( + currentName: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit +) { + var name by remember { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Rename pool") }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Pool name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { if (name.isNotBlank()) onConfirm(name.trim()) }, + enabled = name.isNotBlank() + ) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) } /** Shared appearance picker dialog (System / Light / Dark). */ @@ -369,8 +706,12 @@ private fun FindMyPoolSection( gateways: List, busy: Boolean, onDiscover: () -> Unit, - onConnect: (DiscoveredGateway, String) -> Unit + onConnect: (DiscoveredGateway, String, ConnectSaveOptions) -> Unit ) { + var savePool by rememberSaveable { mutableStateOf(false) } + var rememberPassword by rememberSaveable { mutableStateOf(false) } + var password by remember { mutableStateOf("") } + Column { Button( onClick = onDiscover, @@ -390,6 +731,26 @@ private fun FindMyPoolSection( modifier = Modifier.padding(top = 10.dp) ) } else { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Password (usually blank)") }, + visualTransformation = PasswordVisualTransformation(), + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + ) + SaveConsentBlock( + savePool = savePool, + onSavePool = { + savePool = it + if (!it) rememberPassword = false + }, + rememberPassword = rememberPassword, + onRememberPassword = { rememberPassword = it }, + showRemember = password.isNotBlank() + ) gateways.forEach { gw -> Row( modifier = Modifier @@ -397,7 +758,13 @@ private fun FindMyPoolSection( .padding(top = 10.dp) .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface) - .clickable(enabled = !busy, role = Role.Button) { onConnect(gw, "") } + .clickable(enabled = !busy, role = Role.Button) { + onConnect( + gw, + password, + ConnectSaveOptions(savePool, rememberPassword && savePool) + ) + } .defaultMinSize(minHeight = 56.dp) .padding(horizontal = 14.dp, vertical = 10.dp) .semantics(mergeDescendants = true) {}, @@ -430,14 +797,16 @@ private fun FindMyPoolSection( @Composable private fun RemoteSection( busy: Boolean, - onRemoteConnect: (String, String) -> Unit + onRemoteConnect: (String, String, ConnectSaveOptions) -> Unit ) { var systemCode by rememberSaveable { mutableStateOf("") } var password by remember { mutableStateOf("") } + var savePool by rememberSaveable { mutableStateOf(false) } + var rememberPassword by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( "Your system code is printed on the ScreenLogic label — 6 characters, " + - "like A1-B2-C3. Nothing you enter here is saved on this phone.", + "like A1-B2-C3.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -458,8 +827,24 @@ private fun RemoteSection( singleLine = true, modifier = Modifier.fillMaxWidth() ) + SaveConsentBlock( + savePool = savePool, + onSavePool = { + savePool = it + if (!it) rememberPassword = false + }, + rememberPassword = rememberPassword, + onRememberPassword = { rememberPassword = it }, + showRemember = password.isNotBlank() + ) Button( - onClick = { onRemoteConnect(systemCode, password) }, + onClick = { + onRemoteConnect( + systemCode, + password, + ConnectSaveOptions(savePool, rememberPassword && savePool) + ) + }, enabled = !busy && systemCode.isNotBlank(), modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp), shape = RoundedCornerShape(12.dp) @@ -472,11 +857,13 @@ private fun RemoteSection( @Composable private fun ManualSection( busy: Boolean, - onManualConnect: (String, Int, String) -> Unit + onManualConnect: (String, Int, String, ConnectSaveOptions) -> Unit ) { var ip by rememberSaveable { mutableStateOf("") } var port by rememberSaveable { mutableStateOf("80") } var password by remember { mutableStateOf("") } + var savePool by rememberSaveable { mutableStateOf(false) } + var rememberPassword by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( "Connect straight to a pool controller at a known address on your network.", @@ -508,10 +895,27 @@ private fun ManualSection( singleLine = true, modifier = Modifier.fillMaxWidth() ) + SaveConsentBlock( + savePool = savePool, + onSavePool = { + savePool = it + if (!it) rememberPassword = false + }, + rememberPassword = rememberPassword, + onRememberPassword = { rememberPassword = it }, + showRemember = password.isNotBlank() + ) Button( onClick = { val p = port.toIntOrNull() ?: 80 - if (ip.isNotBlank()) onManualConnect(ip, p, password) + if (ip.isNotBlank()) { + onManualConnect( + ip, + p, + password, + ConnectSaveOptions(savePool, rememberPassword && savePool) + ) + } }, enabled = !busy && ip.isNotBlank(), modifier = Modifier.fillMaxWidth().defaultMinSize(minHeight = 48.dp), @@ -522,6 +926,55 @@ private fun ManualSection( } } +@Composable +private fun SaveConsentBlock( + savePool: Boolean, + onSavePool: (Boolean) -> Unit, + rememberPassword: Boolean, + onRememberPassword: (Boolean) -> Unit, + showRemember: Boolean +) { + Column(modifier = Modifier.padding(top = 4.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onSavePool(!savePool) } + .defaultMinSize(minHeight = 48.dp) + ) { + Checkbox(checked = savePool, onCheckedChange = onSavePool) + Column { + Text("Save this pool on this phone", style = MaterialTheme.typography.bodyMedium) + Text( + "Stored encrypted on this phone. You can remove it later. " + + "Saving never sends equipment commands.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + AnimatedVisibility(visible = savePool && showRemember) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { onRememberPassword(!rememberPassword) } + .defaultMinSize(minHeight = 48.dp) + ) { + Checkbox(checked = rememberPassword, onCheckedChange = onRememberPassword) + Column { + Text("Remember password securely", style = MaterialTheme.typography.bodyMedium) + Text( + "Optional. Off by default. Password is encrypted on this phone only.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } +} + @Composable private fun ConnectProblemCard(title: String, body: String) { Card( @@ -550,3 +1003,12 @@ private fun ConnectProblemCard(title: String, body: String) { } } } + +private fun formatLastConnected(ms: Long): String { + if (ms <= 0L) return "unknown" + return try { + DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(ms)) + } catch (_: Exception) { + "recently" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt index 1d1efd0..a4ae986 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt @@ -46,6 +46,9 @@ import com.pentaircontrol.pool.domain.ConnectionStatus import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.savedpools.SavedPoolId +import com.pentaircontrol.pool.savedpools.SavedPoolSummary +import com.pentaircontrol.pool.savedpools.SavedPoolsListState import com.pentaircontrol.pool.ui.components.rememberNowMs import com.pentaircontrol.pool.ui.presentation.lastUpdatedText import com.pentaircontrol.pool.ui.presentation.summarizeConnection @@ -70,7 +73,14 @@ fun SettingsScreen( savedUnit: TemperatureUnit? = null, /** True when confirmations can be remembered (controller reported an identity). */ canRememberUnit: Boolean = true, - receipts: List + receipts: List, + savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), + onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, + onDeleteSavedPool: (SavedPoolId) -> Unit = {}, + onSaveCurrentPool: () -> Unit = {}, + onMarkPreferred: (SavedPoolId) -> Unit = {}, + onResetVault: () -> Unit = {}, + vaultDiagnostics: String? = null ) { val hasSavedUnit = savedUnit != null Column( @@ -226,6 +236,17 @@ fun SettingsScreen( } } + // Saved pools + SavedPoolsSettingsCard( + connection = connection, + savedPoolsState = savedPoolsState, + onRename = onRenameSavedPool, + onDelete = onDeleteSavedPool, + onSaveCurrent = onSaveCurrentPool, + onMarkPreferred = onMarkPreferred, + onResetVault = onResetVault + ) + // Connection val summary = summarizeConnection(connection, canRetry, hasLastKnownData = site != null) SettingsCard(title = "Connection") { @@ -276,7 +297,8 @@ fun SettingsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - "System codes and passwords are never saved on this phone.", + "Saved pool connections are optional and encrypted on this phone only. " + + "They are not backed up or transferred to another device.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp) @@ -316,6 +338,17 @@ fun SettingsScreen( DiagnosticRow("Connection", summary.technicalDetail) DiagnosticRow("Data source", site?.source?.name ?: "—") DiagnosticRow("Controller", site?.controllerVersion ?: "—") + vaultDiagnostics?.let { + DiagnosticRow("Saved pools vault", it) + } + when (val sp = savedPoolsState) { + is SavedPoolsListState.Available -> + DiagnosticRow("Saved pools", "${sp.pools.size} profile(s)") + is SavedPoolsListState.Unreadable -> + DiagnosticRow("Saved pools", "unreadable (${sp.health})") + is SavedPoolsListState.Loading -> + DiagnosticRow("Saved pools", "loading") + } DiagnosticRow( "Temperature unit", site?.let { "${it.temperatureUnit.name} (${it.temperatureUnitSource.name})" } ?: "—" @@ -419,6 +452,188 @@ fun SettingsScreen( } } +@Composable +private fun SavedPoolsSettingsCard( + connection: ConnectionStatus, + savedPoolsState: SavedPoolsListState, + onRename: (SavedPoolId, String) -> Unit, + onDelete: (SavedPoolId) -> Unit, + onSaveCurrent: () -> Unit, + onMarkPreferred: (SavedPoolId) -> Unit, + onResetVault: () -> Unit +) { + var renameId by remember { mutableStateOf(null) } + var renameName by remember { mutableStateOf("") } + var removeId by remember { mutableStateOf?>(null) } + var showReset by remember { mutableStateOf(false) } + + SettingsCard(title = "Saved pools") { + Text( + "Pools you choose to save are encrypted on this phone. " + + "Passwords are optional and off by default. " + + "Saving or reconnecting never sends equipment commands by itself.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp) + ) + + if (connection.isLiveConnected && connection !is ConnectionStatus.Simulator) { + OutlinedButton( + onClick = onSaveCurrent, + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .padding(bottom = 8.dp) + ) { + Text("Save current pool on this phone") + } + } + + when (savedPoolsState) { + is SavedPoolsListState.Loading -> Text( + "Loading…", + style = MaterialTheme.typography.bodyMedium + ) + is SavedPoolsListState.Unreadable -> { + Text( + "Saved pools could not be opened on this device.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error + ) + TextButton(onClick = { showReset = true }) { + Text("Reset saved pools") + } + } + is SavedPoolsListState.Available -> { + if (savedPoolsState.pools.isEmpty()) { + Text( + "No pools saved on this phone yet.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + savedPoolsState.pools.forEach { pool -> + SavedPoolManageRow( + pool = pool, + onRename = { + renameId = pool.id + renameName = pool.displayName + }, + onRemove = { removeId = pool.id to pool.displayName }, + onPrefer = { onMarkPreferred(pool.id) } + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) + } + TextButton(onClick = { showReset = true }) { + Text("Reset all saved pools") + } + } + } + } + } + + renameId?.let { id -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { renameId = null }, + title = { Text("Rename pool") }, + text = { + androidx.compose.material3.OutlinedTextField( + value = renameName, + onValueChange = { renameName = it }, + label = { Text("Pool name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + onClick = { + if (renameName.isNotBlank()) { + onRename(id, renameName.trim()) + renameId = null + } + }, + enabled = renameName.isNotBlank() + ) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = { renameId = null }) { Text("Cancel") } + } + ) + } + + removeId?.let { (id, name) -> + androidx.compose.material3.AlertDialog( + onDismissRequest = { removeId = null }, + title = { Text("Remove saved pool?") }, + text = { + Text( + "“$name” will be removed from this phone. " + + "If it is currently connected, the connection can continue until you disconnect." + ) + }, + confirmButton = { + TextButton(onClick = { + onDelete(id) + removeId = null + }) { Text("Remove") } + }, + dismissButton = { + TextButton(onClick = { removeId = null }) { Text("Cancel") } + } + ) + } + + if (showReset) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { showReset = false }, + title = { Text("Reset all saved pools?") }, + text = { + Text( + "Removes every saved pool and encrypted connection detail from this phone. " + + "Appearance and other settings stay." + ) + }, + confirmButton = { + TextButton(onClick = { + onResetVault() + showReset = false + }) { Text("Reset") } + }, + dismissButton = { + TextButton(onClick = { showReset = false }) { Text("Cancel") } + } + ) + } +} + +@Composable +private fun SavedPoolManageRow( + pool: SavedPoolSummary, + onRename: () -> Unit, + onRemove: () -> Unit, + onPrefer: () -> Unit +) { + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text(pool.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) + Text( + buildString { + append(pool.routeKindLabel) + if (pool.hasLocalRoute && pool.hasRemoteRoute) append(" · local + remote") + append(" · ") + append(if (pool.passwordRequired) "Password required" else "Ready to connect") + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + TextButton(onClick = onRename) { Text("Rename") } + TextButton(onClick = onPrefer) { Text("Prefer") } + TextButton(onClick = onRemove) { Text("Remove") } + } + } +} + @Composable private fun SettingsCard(title: String, content: @Composable () -> Unit) { Card( diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 4df9255..f30d6c2 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,16 @@ - - - \ No newline at end of file + + + + + + + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 9ee9997..ec823ca 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,23 @@ - - + + + + + + - - \ No newline at end of file + diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt new file mode 100644 index 0000000..247f5a6 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt @@ -0,0 +1,152 @@ +package com.pentaircontrol.pool.savedpools + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.security.SecureRandom +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey + +class AesGcmVaultEnvelopeTest { + + private val key: SecretKey = KeyGenerator.getInstance("AES").apply { + init(256) + }.generateKey() + + @Test + fun roundTrip_succeeds() { + val plain = "hello vault payload".toByteArray() + val env = AesGcmVaultEnvelope.encrypt(key, plain) + val out = AesGcmVaultEnvelope.decrypt(key, env) + assertArrayEquals(plain, out) + } + + @Test + fun samePlaintext_encryptedTwice_differentCiphertext() { + val plain = "same-secret-system-code-ABCD".toByteArray() + val a = AesGcmVaultEnvelope.encrypt(key, plain) + val b = AesGcmVaultEnvelope.encrypt(key, plain) + assertFalse(a.contentEquals(b)) + assertArrayEquals( + AesGcmVaultEnvelope.decrypt(key, a), + AesGcmVaultEnvelope.decrypt(key, b) + ) + } + + @Test + fun systemCodeAndPassword_doNotAppearInCiphertext() { + val systemCode = "Z9-Y8-X7" + val password = "SuperSecretPass99" + val plain = "code=$systemCode;pass=$password".toByteArray() + val env = AesGcmVaultEnvelope.encrypt(key, plain) + val asString = String(env, Charsets.ISO_8859_1) + assertFalse(asString.contains(systemCode)) + assertFalse(asString.contains(password)) + // Also check raw bytes subsequence + assertFalse(env.containsSubsequence(systemCode.toByteArray())) + assertFalse(env.containsSubsequence(password.toByteArray())) + } + + @Test + fun modifiedCiphertext_failsAuthentication() { + val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() + env[env.size - 1] = (env[env.size - 1].toInt() xor 0xFF).toByte() + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.decrypt(key, env) + } + assertEquals(VaultHealth.AUTH_TAG_FAILURE, ex.health) + } + + @Test + fun modifiedIv_failsAuthentication() { + val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() + // IV starts at offset 6 (magic4 + ver1 + ivLen1) + env[6] = (env[6].toInt() xor 0x01).toByte() + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.decrypt(key, env) + } + assertEquals(VaultHealth.AUTH_TAG_FAILURE, ex.health) + } + + @Test + fun wrongAadVersion_failsAuthentication() { + // Encrypt with correct AAD, then decrypt with a cipher that uses wrong AAD by + // forging an otherwise-valid envelope is already covered by tag failure when + // ciphertext is authentic for different AAD — re-encrypt path: + val plain = "data".toByteArray() + val env = AesGcmVaultEnvelope.encrypt(key, plain) + // Tamper magic/version path: wrong version + env[4] = 9 + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.decrypt(key, env) + } + assertEquals(VaultHealth.UNSUPPORTED_SCHEMA, ex.health) + } + + @Test + fun truncatedEnvelope_failsSafely() { + val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()) + val truncated = env.copyOf(10) + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.decrypt(key, truncated) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + + @Test + fun invalidMagic_failsSafely() { + val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() + env[0] = 'X'.code.toByte() + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.decrypt(key, env) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + + @Test + fun writerNeverReusesIv_acrossManyWrites() { + val plain = "x".toByteArray() + val ivs = HashSet() + repeat(64) { + val env = AesGcmVaultEnvelope.encrypt(key, plain) + // IV is 12 bytes starting at offset 6 + val iv = env.copyOfRange(6, 18) + assertTrue(ivs.add(iv.toHex())) + } + } + + @Test + fun secretKeyVaultCipher_roundTrip() { + val cipher = SecretKeyVaultCipher(key) + val plain = byteArrayOf(1, 2, 3, 4, 5) + assertArrayEquals(plain, cipher.decrypt(cipher.encrypt(plain))) + } + + @Test + fun deterministicRandom_stillChangesIvWhenSeededDifferently() { + // Prove encrypt always pulls fresh IV bytes from SecureRandom + val r1 = SecureRandom() + val r2 = SecureRandom() + val a = AesGcmVaultEnvelope.encrypt(key, "p".toByteArray(), r1) + val b = AesGcmVaultEnvelope.encrypt(key, "p".toByteArray(), r2) + // Extremely unlikely to collide + assertFalse(a.contentEquals(b)) + } + + private fun ByteArray.containsSubsequence(needle: ByteArray): Boolean { + if (needle.isEmpty() || needle.size > size) return false + outer@ for (i in 0..size - needle.size) { + for (j in needle.indices) { + if (this[i + j] != needle[j]) continue@outer + } + return true + } + return false + } + + private fun ByteArray.toHex(): String = + joinToString("") { b -> "%02x".format(b) } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt new file mode 100644 index 0000000..431aefe --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt @@ -0,0 +1,204 @@ +package com.pentaircontrol.pool.savedpools + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test + +class SavedPoolCodecTest { + + @Test + fun version1_singleProfile_roundTrip() { + val original = listOf(sampleRemoteProfile()) + val bytes = SavedPoolCodec.encode(original) + val decoded = SavedPoolCodec.decode(bytes) + assertEquals(1, decoded.size) + assertEquals(original[0].id, decoded[0].id) + assertEquals(original[0].displayName, decoded[0].displayName) + assertEquals(original[0].adapterId, decoded[0].adapterId) + assertEquals(original[0].controllerIdentity, decoded[0].controllerIdentity) + val r = decoded[0].routes[0] as SavedRoute.ScreenLogicRemote + assertEquals("AB-CD-EF", r.systemCode) + assertNull(r.password) + assertTrue(r.passwordRequired) + } + + @Test + fun multipleProfiles_roundTrip() { + val original = listOf( + sampleRemoteProfile(id = "p1", name = "Home"), + sampleLocalProfile(id = "p2", name = "Cabin") + ) + val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(original)) + assertEquals(2, decoded.size) + assertEquals("Home", decoded[0].displayName) + assertEquals("Cabin", decoded[1].displayName) + assertTrue(decoded[1].routes[0] is SavedRoute.ScreenLogicLocal) + } + + @Test + fun remoteAndLocalRoutes_roundTrip() { + val profile = SavedPoolProfile( + id = SavedPoolId("both"), + displayName = "Both", + adapterId = "pentair.screenlogic", + controllerIdentity = "ctrl-1", + createdAtMs = 1L, + lastSuccessAtMs = 2L, + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + routes = mutableListOf( + SavedRoute.ScreenLogicRemote("CODE11", "secret", false, false), + SavedRoute.ScreenLogicLocal( + address = "192.168.1.10", + port = 80, + gatewayLabel = "GW", + password = null, + passwordRequired = false, + blankPasswordSucceeded = true, + controllerIdentity = "ctrl-1" + ) + ) + ) + val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(listOf(profile))) + assertEquals(2, decoded[0].routes.size) + assertEquals("CODE11", (decoded[0].routes[0] as SavedRoute.ScreenLogicRemote).systemCode) + assertEquals("secret", (decoded[0].routes[0] as SavedRoute.ScreenLogicRemote).password) + assertEquals( + "192.168.1.10", + (decoded[0].routes[1] as SavedRoute.ScreenLogicLocal).address + ) + } + + @Test + fun missingOptionalPassword_roundTrip() { + val p = sampleRemoteProfile(password = null, passwordRequired = true) + val r = SavedPoolCodec.decode(SavedPoolCodec.encode(listOf(p)))[0] + .routes[0] as SavedRoute.ScreenLogicRemote + assertNull(r.password) + assertTrue(r.passwordRequired) + } + + @Test + fun unknownFutureVersion_rejected() { + val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) + // Bump schema version at bytes[4..5] + val bad = good.copyOf() + bad[4] = 0 + bad[5] = 99 // version 99 + val ex = assertThrows(VaultException::class.java) { + SavedPoolCodec.decode(bad) + } + assertEquals(VaultHealth.UNSUPPORTED_SCHEMA, ex.health) + } + + @Test + fun truncatedPayload_rejected() { + val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) + val truncated = good.copyOf(good.size / 2) + val ex = assertThrows(VaultException::class.java) { + SavedPoolCodec.decode(truncated) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + + @Test + fun corruptMagic_rejected() { + val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) + good[0] = 'X'.code.toByte() + val ex = assertThrows(VaultException::class.java) { + SavedPoolCodec.decode(good) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + + @Test + fun malformedStringLength_rejectedWithoutHugeAllocation() { + // Build minimal header then huge length + val baos = java.io.ByteArrayOutputStream() + java.io.DataOutputStream(baos).use { out -> + out.writeBytes("WCSV") + out.writeShort(1) + out.writeInt(1) + out.writeInt(Int.MAX_VALUE) // invalid string length + } + val ex = assertThrows(VaultException::class.java) { + SavedPoolCodec.decode(baos.toByteArray()) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + + @Test + fun duplicateProfileIds_rejectedOnEncode() { + val a = sampleRemoteProfile(id = "dup") + val b = sampleRemoteProfile(id = "dup", name = "Other") + assertThrows(IllegalArgumentException::class.java) { + SavedPoolCodec.encode(listOf(a, b)) + } + } + + @Test + fun toString_redactsSecrets() { + val remote = SavedRoute.ScreenLogicRemote("SECRET-CODE", "hunter2", true, false) + val text = remote.toString() + assertTrue(!text.contains("SECRET-CODE")) + assertTrue(!text.contains("hunter2")) + assertTrue(text.contains("***")) + + val local = SavedRoute.ScreenLogicLocal( + "10.0.0.5", 80, "GW", "pw", true, false, null + ) + val localText = local.toString() + assertTrue(!localText.contains("10.0.0.5")) + assertTrue(!localText.contains("pw")) + } + + companion object { + fun sampleRemoteProfile( + id: String = "id-1", + name: String = "Pool", + password: String? = null, + passwordRequired: Boolean = true + ) = SavedPoolProfile( + id = SavedPoolId(id), + displayName = name, + adapterId = "pentair.screenlogic", + controllerIdentity = null, + createdAtMs = 100L, + lastSuccessAtMs = 200L, + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + routes = mutableListOf( + SavedRoute.ScreenLogicRemote( + systemCode = "AB-CD-EF", + password = password, + passwordRequired = passwordRequired, + blankPasswordSucceeded = false + ) + ) + ) + + fun sampleLocalProfile( + id: String = "id-2", + name: String = "Local" + ) = SavedPoolProfile( + id = SavedPoolId(id), + displayName = name, + adapterId = "pentair.screenlogic", + controllerIdentity = "ctrl-local", + createdAtMs = 100L, + lastSuccessAtMs = 300L, + preferredRoute = SavedRouteKind.SCREENLOGIC_LOCAL, + routes = mutableListOf( + SavedRoute.ScreenLogicLocal( + address = "192.168.0.50", + port = 80, + gatewayLabel = "ScreenLogic", + password = null, + passwordRequired = false, + blankPasswordSucceeded = true, + controllerIdentity = "ctrl-local" + ) + ) + ) + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt new file mode 100644 index 0000000..f6d16a5 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt @@ -0,0 +1,350 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.Capability +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import com.pentaircontrol.pool.ui.PoolViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import javax.crypto.KeyGenerator + +/** + * Connection behavior for saved-pool reconnect through SiteSession + ViewModel. + * Proves no second connection engine and no equipment commands on reconnect. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SavedPoolConnectionBehaviorTest { + + private lateinit var cipher: VaultCipher + private lateinit var store: InMemoryVaultFileStore + private lateinit var repo: DefaultSavedPoolRepository + private lateinit var prefs: InMemoryAppPreferencesStore + private val mainDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(mainDispatcher) + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + cipher = SecretKeyVaultCipher(key) + store = InMemoryVaultFileStore() + prefs = InMemoryAppPreferencesStore() + repo = DefaultSavedPoolRepository(cipher, store, prefs) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun oneTapReconnect_usesExistingSiteSession() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "AB-CD-EF", "pw", "Home", null, savePassword = true + ) + ) + val adapter = RecordingAdapter() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget) { adapter } + assertTrue(session.connection.value.isLiveConnected) + assertEquals(1, adapter.connectCount) + assertEquals(0, adapter.commandCount) + session.close() + } + + @Test + fun missingPassword_producesPasswordRequired() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "AB-CD-EF", "pw", "Home", null, savePassword = false + ) + ) + val result = repo.loadConnection(summary.id) + assertTrue(result is SavedPoolConnectionResult.PasswordRequired) + val pr = result as SavedPoolConnectionResult.PasswordRequired + assertEquals("Home", pr.displayName) + assertFalse(pr.toString().contains("AB-CD-EF")) + assertFalse(pr.toString().contains("pw")) + } + + @Test + fun passwordPrompt_doesNotExposeSystemCode() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "TOPSECRET", "pw", "Home", null, savePassword = false + ) + ) + val result = repo.loadConnection(summary.id) as SavedPoolConnectionResult.PasswordRequired + val prompt = SavedPoolPasswordPrompt(result.profileId, result.displayName, result.routeKind) + assertFalse(prompt.toString().contains("TOPSECRET")) + assertFalse(prompt.toString().contains("pw")) + } + + @Test + fun failedReconnect_preservesProfile() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "AB-CD-EF", "pw", "Home", null, savePassword = true, timestampMs = 500 + ) + ) + val lastBefore = repo.list()[0].lastSuccessAtMs + val adapter = RecordingAdapter(failConnect = true) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget) { adapter } + assertFalse(session.connection.value.isLiveConnected) + val still = repo.list() + assertEquals(1, still.size) + assertEquals(lastBefore, still[0].lastSuccessAtMs) + session.close() + } + + @Test + fun successfulReconnect_updatesLastUsedTime() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "AB-CD-EF", "", "Home", null, false, timestampMs = 100 + ) + ) + val adapter = RecordingAdapter() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget) { adapter } + assertTrue(session.connection.value.isLiveConnected) + repo.recordSuccessfulConnection(summary.id, 9999L) + assertEquals(9999L, repo.list()[0].lastSuccessAtMs) + session.close() + } + + @Test + fun noEquipmentCommand_duringSavedPoolReconnect() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("AB", "pw", "H", null, true) + ) + val adapter = RecordingAdapter() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget) { adapter } + assertEquals(0, adapter.commandCount) + session.close() + } + + @Test + fun disconnect_clearsActiveAdapter() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("AB", "pw", "H", null, true) + ) + val adapter = RecordingAdapter() + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget) { adapter } + assertTrue(session.connection.value.isLiveConnected) + session.disconnect() + assertNull(session.adapterForTest()) + session.close() + } + + @Test + fun viewModel_saveOff_doesNotWriteVault() = runBlocking { + val adapter = RecordingAdapter() + val vm = PoolViewModel( + preferences = prefs, + savedPools = repo, + sessionFactory = { + SiteSession( + scope = it, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + } + ) + delay(20) + vm.connectRemote("AB-CD-EF", "pw", ConnectSaveOptions(savePool = false)) + delay(50) + assertTrue(repo.list().isEmpty()) + vm.clearForTest() + } + + @Test + fun viewModel_saveOn_writesVaultAfterSuccess() = runBlocking { + val adapter = RecordingAdapter() + val vm = PoolViewModel( + preferences = prefs, + savedPools = repo, + sessionFactory = { + SiteSession( + scope = it, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + } + ) + delay(20) + vm.connectRemote( + "AB-CD-EF", + "pw", + ConnectSaveOptions(savePool = true, rememberPassword = false) + ) + delay(80) + assertEquals(1, repo.list().size) + assertTrue(repo.list()[0].passwordRequired) + assertTrue(prefs.snapshot().values.none { + it.contains("AB-CD-EF") || it.contains("pw") + }) + vm.clearForTest() + } + + @Test + fun viewModel_passwordRequired_setsPromptWithoutSecrets() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("SECRETCODE", "pw", "Home", null, false) + ) + val adapter = RecordingAdapter() + val vm = PoolViewModel( + preferences = prefs, + savedPools = repo, + sessionFactory = { + SiteSession( + scope = it, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + } + ) + delay(20) + vm.connectSavedPool(summary.id) + delay(50) + val prompt = vm.passwordPrompt.value + assertNotNull(prompt) + assertEquals("Home", prompt!!.displayName) + assertFalse(prompt.toString().contains("SECRETCODE")) + assertFalse(vm.connection.value.isLiveConnected) + vm.clearForTest() + } + + private class RecordingAdapter( + val failConnect: Boolean = false, + val slowConnectMs: Long = 0L + ) : PoolSystemAdapter { + override val adapterId = "fake.sl" + override val displayName = "Fake" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + var connectCount = 0 + var commandCount = 0 + + override suspend fun connect(target: AdapterConnectionTarget) { + connectCount++ + if (slowConnectMs > 0) delay(slowConnectMs) + if (failConnect) { + _c.value = ConnectionStatus.ConnectionFailed("fail") + throw IllegalStateException("fail") + } + val unit = TemperatureUnit.FAHRENHEIT + val snap = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), + BodyKind.POOL, + "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + airTemp = Temperature(70, unit), + temperatureUnit = unit, + temperatureUnitSource = TemperatureUnitSource.CONTROLLER, + source = ConnectionPath.REMOTE_MANUFACTURER, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _s.value = snap + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + } + + override suspend fun disconnect() { + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot { + return _s.value ?: error("no snap") + } + + override suspend fun sendCommand(command: DomainCommand) { + commandCount++ + } + + override suspend fun discoverGateways() = emptyList() + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt new file mode 100644 index 0000000..9bb8926 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt @@ -0,0 +1,362 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.settings.ControllerTempUnitStore +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import com.pentaircontrol.pool.domain.TemperatureUnit +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import javax.crypto.KeyGenerator + +class SavedPoolRepositoryTest { + + private lateinit var cipher: VaultCipher + private lateinit var store: InMemoryVaultFileStore + private lateinit var prefs: InMemoryAppPreferencesStore + private lateinit var repo: DefaultSavedPoolRepository + + @Before + fun setUp() { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + cipher = SecretKeyVaultCipher(key) + store = InMemoryVaultFileStore() + prefs = InMemoryAppPreferencesStore() + repo = DefaultSavedPoolRepository(cipher, store, prefs, maxProfiles = 5) + } + + @Test + fun onlySuccessfulOptedIn_saves() = runBlocking { + val draft = SavedPoolDraftFactory.fromRemote( + systemCode = "AB-CD-EF", + password = "pw", + displayName = "Home", + controllerIdentity = null, + savePassword = false + ) + repo.saveAfterSuccessfulConnection(draft) + assertEquals(1, repo.list().size) + } + + @Test + fun saveOff_neverSaves_viaFactoryNotCalled() = runBlocking { + // Repository only called when save opted in; empty vault + assertTrue(repo.list().isEmpty()) + assertEquals(VaultHealth.EMPTY, repo.health()) + } + + @Test + fun passwordOmittedByDefault() = runBlocking { + val draft = SavedPoolDraftFactory.fromRemote( + systemCode = "AB-CD-EF", + password = "secret-pw", + displayName = "Home", + controllerIdentity = null, + savePassword = false + ) + val summary = repo.saveAfterSuccessfulConnection(draft) + assertTrue(summary.passwordRequired) + assertFalse(summary.readyToConnect) + val loaded = repo.loadConnection(summary.id) + assertTrue(loaded is SavedPoolConnectionResult.PasswordRequired) + } + + @Test + fun rememberPassword_savesAfterSuccess() = runBlocking { + val draft = SavedPoolDraftFactory.fromRemote( + systemCode = "AB-CD-EF", + password = "secret-pw", + displayName = "Home", + controllerIdentity = null, + savePassword = true + ) + val summary = repo.saveAfterSuccessfulConnection(draft) + assertFalse(summary.passwordRequired) + assertTrue(summary.readyToConnect) + val loaded = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + val target = loaded.connectionTarget as ScreenLogicRemoteTarget + assertEquals("secret-pw", target.password) + // Ready target must not leak via toString + assertFalse(loaded.toString().contains("secret-pw")) + assertFalse(loaded.toString().contains("AB-CD-EF")) + } + + @Test + fun blankPassword_succeeds_recordsNoPasswordRequired() = runBlocking { + val draft = SavedPoolDraftFactory.fromRemote( + systemCode = "AB-CD-EF", + password = "", + displayName = "Home", + controllerIdentity = null, + savePassword = false + ) + val summary = repo.saveAfterSuccessfulConnection(draft) + assertFalse(summary.passwordRequired) + assertTrue(summary.readyToConnect) + val loaded = repo.loadConnection(summary.id) + assertTrue(loaded is SavedPoolConnectionResult.Ready) + } + + @Test + fun rename_preservesEncryptedRoute() = runBlocking { + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "CODE01", "pw", "Old", null, savePassword = true + ) + ) + repo.rename(summary.id, "New Name") + val list = repo.list() + assertEquals("New Name", list[0].displayName) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + assertEquals("pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + } + + @Test + fun remove_deletesOnlyOneProfile() = runBlocking { + val a = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("A1", "", "A", null, false) + ) + val b = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("B2", "", "B", null, false) + ) + repo.delete(a.id) + val remaining = repo.list() + assertEquals(1, remaining.size) + assertEquals(b.id, remaining[0].id) + } + + @Test + fun sameControllerIdentity_mergesLocalAndRemote() = runBlocking { + val identity = "pentair.screenlogic:controller:42" + val remote = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("CODE", "pw", "Home", identity, true) + ) + val local = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + gateway = DiscoveredGateway("g", "GW", "10.0.0.1", 80), + password = "", + controllerIdentity = identity, + savePassword = false, + displayName = "Home LAN" + ) + ) + assertEquals(remote.id, local.id) + assertEquals(1, repo.list().size) + val summary = repo.list()[0] + assertTrue(summary.hasLocalRoute) + assertTrue(summary.hasRemoteRoute) + } + + @Test + fun differentControllerIdentities_doNotMerge() = runBlocking { + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C1", "", "One", "id-1", false) + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C2", "", "Two", "id-2", false) + ) + assertEquals(2, repo.list().size) + } + + @Test + fun missingIdentity_remainsSeparate() = runBlocking { + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C1", "", "One", null, false) + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C2", "", "Two", null, false) + ) + assertEquals(2, repo.list().size) + } + + @Test + fun lastSuccess_ordering_deterministic() = runBlocking { + val older = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C1", "", "Old", null, false, timestampMs = 100) + ) + val newer = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C2", "", "New", null, false, timestampMs = 200) + ) + val list = repo.list() + assertEquals(newer.id, list[0].id) + assertEquals(older.id, list[1].id) + } + + @Test + fun maxProfileCount_dropsOldest() = runBlocking { + repeat(6) { i -> + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "C$i", "", "P$i", "id-$i", false, timestampMs = i.toLong() + ) + ) + } + val list = repo.list() + assertEquals(5, list.size) + assertFalse(list.any { it.displayName == "P0" }) + } + + @Test + fun concurrentSaves_doNotCorruptVault() = runBlocking { + val jobs = (0 until 10).map { i -> + async { + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "CODE$i", "", "P$i", "ctrl-$i", false, timestampMs = i.toLong() + ) + ) + } + } + jobs.awaitAll() + // maxProfiles=5 so at most 5 remain, vault still readable + val list = repo.list() + assertTrue(list.size in 1..5) + assertEquals(VaultHealth.AVAILABLE, repo.health()) + } + + @Test + fun failedDecrypt_doesNotOverwriteVault() = runBlocking { + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("KEEP", "pw", "Keep", null, true) + ) + val goodEnvelope = store.read()!! + // Install a broken cipher that always fails decrypt + val broken = object : VaultCipher { + override fun encrypt(plaintext: ByteArray): ByteArray = cipher.encrypt(plaintext) + override fun decrypt(envelope: ByteArray): ByteArray { + throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "auth") + } + } + val brokenRepo = DefaultSavedPoolRepository(broken, store, prefs) + try { + brokenRepo.list() + fail("expected vault exception") + } catch (e: VaultException) { + assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health) + } + // Prior vault bytes untouched + assertArrayEquals(goodEnvelope, store.read()!!) + } + + @Test + fun delete_clearsControllerTempUnitWhenIdentityKnown() = runBlocking { + val identity = "pentair.screenlogic:controller:99" + ControllerTempUnitStore.save(prefs, identity, TemperatureUnit.CELSIUS) + val summary = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C", "", "X", identity, false) + ) + assertNotNull(ControllerTempUnitStore.load(prefs, identity)) + repo.delete(summary.id) + assertNull(ControllerTempUnitStore.load(prefs, identity)) + } + + @Test + fun resetVault_clearsProfilesAndKeyCallback() = runBlocking { + var keyReset = false + val r = DefaultSavedPoolRepository(cipher, store, prefs, onKeyReset = { keyReset = true }) + r.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C", "", "X", null, false) + ) + r.resetVault() + assertTrue(keyReset) + assertTrue(r.list().isEmpty()) + assertFalse(store.exists()) + } + + @Test + fun updatePasswordAfterSuccess() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C", "old", "X", null, savePassword = false) + ) + assertTrue(s.passwordRequired) + repo.updatePasswordAfterSuccess( + s.id, SavedRouteKind.SCREENLOGIC_REMOTE, "new-pw", rememberPassword = true + ) + val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready + assertEquals("new-pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + } + + @Test + fun localRoute_loadConnection_buildsTarget() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "Label", "192.168.1.20", 80), + password = "", + controllerIdentity = null, + savePassword = false + ) + ) + val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready + val local = ready.connectionTarget as ScreenLogicLocalTarget + assertEquals("192.168.1.20", local.gateway.address) + assertEquals(80, local.gateway.port) + } + + @Test + fun summary_containsNoSecrets() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "TOP-SECRET", "hunter2", "My Pool", null, true + ) + ) + val text = s.toString() + assertFalse(text.contains("TOP-SECRET")) + assertFalse(text.contains("hunter2")) + assertTrue(text.contains("My Pool")) + } + + @Test + fun ordinaryPreferences_containNoSecretsAfterSave() = runBlocking { + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "SYS-CODE", "the-password", "Pool", null, true + ) + ) + val snap = prefs.snapshot() + assertTrue(snap.isEmpty() || snap.values.none { + it.contains("SYS-CODE") || it.contains("the-password") + }) + // Vault is not in preferences + assertNull(prefs.getString("saved_pools")) + assertNull(prefs.getString("westcurrent.last_system_code")) + } + + @Test + fun updateLocalAddress_identityMismatch_refuses() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "L", "10.0.0.1", 80), + "", + controllerIdentity = "ctrl-A", + savePassword = false + ) + ) + repo.updateLocalAddress(s.id, "10.0.0.2", 80, "Other", controllerIdentity = "ctrl-B") + val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready + val local = ready.connectionTarget as ScreenLogicLocalTarget + // Address must remain original + assertEquals("10.0.0.1", local.gateway.address) + } + + @Test + fun vaultException_toString_hasNoSecrets() { + val ex = VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed") + assertFalse(ex.toString().contains("password")) + assertTrue(ex.toString().contains("AUTH_TAG_FAILURE")) + } + + private fun assertArrayEquals(a: ByteArray, b: ByteArray) { + assertEquals(a.size, b.size) + for (i in a.indices) assertEquals(a[i], b[i]) + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt new file mode 100644 index 0000000..8bd057c --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt @@ -0,0 +1,190 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import javax.crypto.KeyGenerator +import kotlinx.coroutines.runBlocking + +/** + * Security surface tests: ordinary prefs, redaction, vault path, backup XML exclusions. + */ +class SavedPoolSecurityAndBackupTest { + + @Test + fun ordinaryPreferences_containNoSecrets_afterSave() = runBlocking { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val prefs = InMemoryAppPreferencesStore() + val repo = DefaultSavedPoolRepository( + SecretKeyVaultCipher(key), + InMemoryVaultFileStore(), + prefs + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + systemCode = "SYS-CODE-99", + password = "password-value", + displayName = "Pool", + controllerIdentity = null, + savePassword = true + ) + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + gateway = DiscoveredGateway("g", "GW", "192.168.44.55", 8080), + password = "local-pass", + controllerIdentity = null, + savePassword = true + ) + ) + val snap = prefs.snapshot() + val joined = snap.entries.joinToString { "${it.key}=${it.value}" } + assertFalse(joined.contains("SYS-CODE-99")) + assertFalse(joined.contains("password-value")) + assertFalse(joined.contains("192.168.44.55")) + assertFalse(joined.contains("local-pass")) + // No digests of secrets should be stored either as convenience history keys + assertFalse(snap.keys.any { it.contains("system_code", ignoreCase = true) }) + assertFalse(snap.keys.any { it.contains("password", ignoreCase = true) }) + } + + @Test + fun uiSummaries_containNoSecrets() = runBlocking { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val repo = DefaultSavedPoolRepository( + SecretKeyVaultCipher(key), + InMemoryVaultFileStore() + ) + val s = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "SECRETCODE", "hunter2", "Friendly Name", null, true + ) + ) + assertFalse(s.toString().contains("SECRETCODE")) + assertFalse(s.toString().contains("hunter2")) + assertTrue(s.displayName == "Friendly Name") + assertFalse(s.displayName.contains("SECRET")) + } + + @Test + fun secureFile_isUnderNoBackupFilesDir_pattern() { + val noBackup = File(System.getProperty("java.io.tmpdir"), "wc-no-backup-test") + noBackup.mkdirs() + val store = AtomicVaultFileStore(noBackup) + store.writeAtomic(byteArrayOf(1, 2, 3, 4)) + assertTrue(store.exists()) + assertTrue(store.filePathForDiagnostics().contains("saved_pools_v1.enc")) + assertTrue(store.directoryForDiagnostics().contains("wc-no-backup-test")) + store.delete() + noBackup.deleteRecursively() + } + + @Test + fun atomicWrite_replacesPriorContent() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic-test-${System.nanoTime()}") + dir.mkdirs() + val store = AtomicVaultFileStore(dir) + store.writeAtomic(byteArrayOf(9, 9, 9)) + store.writeAtomic(byteArrayOf(1, 2, 3)) + val read = store.read()!! + assertTrue(read.contentEquals(byteArrayOf(1, 2, 3))) + store.delete() + dir.deleteRecursively() + } + + @Test + fun backupRulesXml_excludesVaultFile() { + val backup = File("src/main/res/xml/backup_rules.xml").readText() + assertTrue(backup.contains("saved_pools_v1.enc")) + assertTrue(backup.contains(" it.name to line } } + .filter { (_, line) -> + line.contains("EncryptedSharedPreferences") || + line.contains("MasterKey") || + line.contains("androidx.security.crypto") + } + .toList() + assertTrue( + "Deprecated encrypted prefs must not be used: $hits", + hits.isEmpty() + ) + } + + @Test + fun keystoreAlias_isVersioned() { + assertTrue(AndroidKeystoreVaultCipher.KEY_ALIAS.contains("v1")) + assertTrue(AndroidKeystoreVaultCipher.KEY_ALIAS.startsWith("westcurrent.saved_pools")) + } + + @Test + fun reset_removesOnlyVault() = runBlocking { + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + val prefs = InMemoryAppPreferencesStore() + prefs.putString("westcurrent.appearance", "dark") + var keyDeleted = false + val store = InMemoryVaultFileStore() + val repo = DefaultSavedPoolRepository( + SecretKeyVaultCipher(key), + store, + prefs, + onKeyReset = { keyDeleted = true } + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote("C", "p", "N", null, true) + ) + repo.resetVault() + assertTrue(keyDeleted) + assertFalse(store.exists()) + assertTrue(repo.list().isEmpty()) + // Unrelated prefs retained + assertTrue(prefs.getString("westcurrent.appearance") == "dark") + } + + @Test + fun draftAndProfile_toString_redacted() { + val draft = SavedPoolDraftFactory.fromRemote( + "CODECODE", "passpass", "Name", null, true + ) + assertFalse(draft.toString().contains("CODECODE")) + assertFalse(draft.toString().contains("passpass")) + val profile = SavedPoolProfile( + id = SavedPoolId("x"), + displayName = "N", + adapterId = "a", + controllerIdentity = "id", + createdAtMs = 1, + lastSuccessAtMs = 1, + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + routes = mutableListOf( + SavedRoute.ScreenLogicRemote("CODECODE", "passpass", false, false) + ) + ) + assertFalse(profile.toString().contains("CODECODE")) + assertFalse(profile.toString().contains("passpass")) + } +} diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md new file mode 100644 index 0000000..d26bf1a --- /dev/null +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -0,0 +1,305 @@ +# Phase 1.6 — Saved Pools and Secure Reconnect + +**Product:** WestCurrent by 8 West Ventures, LLC +**Scope:** Securely save successfully connected pool systems and reconnect later without re-entering the controller address or ScreenLogic system code every time. +**Not in scope:** Pool-equipment features, auto-connect on launch, biometric unlock, cloud sync, Fable UI polish. + +Fable did **not** implement security or architecture for this sprint. + +--- + +## 1. Threat model + +### Assets + +| Asset | Sensitivity | +| --- | --- | +| ScreenLogic system code | Secret | +| Local / remote access password | Secret | +| Saved local controller address and port | Sensitive connection material | +| Complete serialized connection target / route | Secret | +| Any deterministic digest of system code or password | Forbidden | +| User-facing pool name, last-used time, route label | Non-secret (still stored in vault) | +| Android Keystore AES key | Key material (never exported) | + +### Adversaries considered + +- Casual inspection of ordinary SharedPreferences / DataStore +- Android cloud backup and device-to-device transfer +- Logcat, crash reports, exception messages, `toString()` dumps +- Screenshots of UI (no secrets on cards) +- Stale or corrupted vault files after app update or Keystore invalidation + +### Explicit non-goals (Phase 1.6) + +- Protecting secrets from a fully compromised running app process +- Biometric / lock-screen binding +- Multi-device sync +- Server-side credential storage + +### Absolute prohibitions (enforced in design) + +- No ordinary SharedPreferences / unencrypted DataStore for connection history +- No deprecated `EncryptedSharedPreferences` or `MasterKey` +- No invented crypto; AES-GCM via platform Keystore only +- No key derivation from password, system code, controller ID, Android ID, serial, or package name +- No hard-coded keys, IVs, salts, passwords, or system codes +- No AES-GCM IV reuse +- No plaintext fallback when encryption fails +- No silent overwrite of an undecryptable vault +- No auto-connect on app launch +- No save of a connection that never succeeded + +--- + +## 2. Security boundaries + +| Layer | Responsibility | +| --- | --- | +| `SavedPoolSummary` / UI state | Non-secret only | +| `SavedPoolRepository` | Merge, consent, load/decrypt for connect | +| `SavedPoolCodec` | Pure Kotlin versioned serialization of decrypted payload | +| `VaultCipher` / `AesGcmVaultEnvelope` | AES-GCM envelope with AAD | +| `AndroidKeystoreVaultCipher` | Keystore-backed AES-256 key | +| `AtomicVaultFileStore` | Atomic write under `noBackupFilesDir` | +| `SiteSession` | Existing connection engine (generation, cancel, reconnect) | + +Decrypted connection material exists only long enough to build an adapter-owned `AdapterConnectionTarget` and begin `SiteSession` connect. It is **never** placed in `StateFlow`, Compose state, diagnostics models, or public summaries. + +--- + +## 3. Keystore key specification + +| Property | Value | +| --- | --- | +| Provider | `AndroidKeyStore` | +| Alias | `westcurrent.saved_pools.aes.v1` | +| Algorithm | AES | +| Key size | 256 bits | +| Purposes | Encrypt, Decrypt | +| Block mode | GCM | +| Padding | None | +| Randomized encryption | Required | +| User authentication | **Not** required (Phase 1.6) | +| StrongBox | Preferred when available; **not** required | + +**Important:** Android Keystore protects the encryption key from extraction; it does **not** protect a compromised running app that can already call decrypt APIs. + +Keystore and disk I/O run on `Dispatchers.IO`, not the main thread. + +--- + +## 4. Cipher-envelope format + +Magic: `WCAE` (ASCII) +Version: `1` (u8) +Layout: + +``` +magic[4] = "WCAE" +version u8 = 1 +ivLen u8 = 12 +iv[12] +ciphertext || GCM tag (128-bit tag) +``` + +**AAD (associated authenticated data):** UTF-8 string +`WestCurrentSavedPools:v1` + +IV is random per write via `SecureRandom`. Never reused with the same key by the writer. + +--- + +## 5. Secure storage path + +``` +context.noBackupFilesDir / saved_pools_v1.enc +``` + +Atomic write pattern: + +1. Encrypt complete new vault payload +2. Write `saved_pools_v1.enc.tmp` +3. Flush / best-effort `fsync` +4. Rename over prior file (with backup rename fallback) + +A crash leaves either the prior valid vault or the new valid vault. + +--- + +## 6. Backup and device-transfer exclusions + +- `android:allowBackup="true"` remains for non-sensitive preferences +- `fullBackupContent` → `@xml/backup_rules` excludes vault filenames +- `dataExtractionRules` → `@xml/data_extraction_rules` excludes vault from **cloud-backup** and **device-transfer** +- Vault lives under `noBackupFilesDir` (platform excludes from legacy full backup) + +The secure vault must never leave the device through Android backup. + +--- + +## 7. Saved-profile schema (payload v1) + +Decrypted payload magic: `WCSV`, schema version `1`. + +Per profile: + +- Opaque random profile ID (`UUID`) +- Display name +- Adapter ID +- Optional controller-reported identity +- Created / last-success timestamps +- Preferred route kind +- One or more routes + +### Routes + +**ScreenLogic remote (`sl_remote`)** + +- Full system code +- Optional password +- `passwordRequired` +- `blankPasswordSucceeded` + +**ScreenLogic local (`sl_local`)** + +- Address, port, gateway label +- Optional password +- `passwordRequired` / `blankPasswordSucceeded` +- Optional controller identity on the route + +Vendor fields stay outside shared domain models; routes are vendor-owned in the vault package. + +--- + +## 8. Local and remote route designs + +### Remote reconnect + +1. Decrypt system code only when connecting +2. Resolve through existing ScreenLogic remote flow (`SiteSession.connectWithFactory`) +3. Use saved password only if user opted in +4. Otherwise prompt **only** for password +5. Auth failure keeps the profile +6. Optional password update only after a successful authenticated connection + +### Local reconnect + +1. Try last-known address once +2. On success, verify controller identity when both expected and actual are known +3. Identity conflict → disconnect candidate; do not install +4. Address failure → user may use existing **Find on Wi-Fi** discovery (no background scanner) +5. Successful discovery reconnect with matching identity may update saved local address +6. Missing identity → conservative; do not silently replace routes with unrelated devices + +--- + +## 9. Consent and password behavior + +| Control | Default | +| --- | --- | +| Save this pool on this phone | **Off** | +| Remember password securely | **Off** (only shown when Save is on and password non-blank) | + +- Nothing is saved until the connection **succeeds** +- Failed connects create no profile and do not update secrets +- Blank password that succeeds records “no password required” +- Password-only prompt on reconnect offers Remember again (default off) + +Copy states: encrypted on this phone; password optional; removable later; save/reconnect never sends equipment commands by itself. + +--- + +## 10. Controller-identity merge rules + +- After success, controller-reported identity (when available) may merge remote + local routes into one profile +- DHCP address change must not create a duplicate when identity proves same controller +- Different identities never merge automatically +- Missing / placeholder identity: save allowed; profile stays independent until identity is known +- Identity is **never** an encryption key or a substitute for the system code + +--- + +## 11. One-tap reconnect lifecycle + +1. User taps Recent pool (no auto-connect on launch) +2. `loadConnection` on IO dispatcher +3. If password required → password dialog only (no system code / address re-entry) +4. Build adapter-owned target +5. `SiteSession.connectWithFactory` — same generation, cancellation, reconnect protections +6. On success: update last-success; optional password remember; optional local address update +7. On failure: keep profile and last-success timestamp; plain-language error; allow retry / rename / remove + +No second connection engine. No equipment command is submitted by reconnect itself. + +--- + +## 12. Vault corruption / invalidation handling + +Explicit `VaultHealth` outcomes: Available, Empty, Keystore missing, Key invalidated, Auth tag failure, Unsupported schema, Corrupt, IO failure, Locked/unavailable. + +**Fail-closed:** + +- Do not regenerate key and overwrite silently +- Do not show partial profiles +- Do not fall back to plaintext +- Message: “Saved pools could not be opened on this device.” +- Offer: Retry · Reset saved pools (confirmed) + +Reset removes: vault file, Keystore alias for saved pools, saved-pool preference references tied to removed profiles. Does **not** erase appearance or unrelated settings. + +--- + +## 13. Logging and redaction + +- Secret-bearing types are not naive data classes with default `toString` +- `toString()` redacts system code, password, full address +- Diagnostics may show profile ID, adapter ID, route type, last success, Keystore security level label, vault schema, vault health +- No “show secret” feature + +--- + +## 14. Tests + +Categories: + +- Serialization (codec round-trip, corrupt, version, allocation bounds) +- Encryption envelope (GCM auth, IV uniqueness, AAD/version, secret absence in ciphertext) +- Repository (consent, merge, remove, concurrent save, fail-closed) +- Connection behavior (SiteSession path, password required, no commands) +- Security / backup XML / no EncryptedSharedPreferences + +Existing Phase 1.x tests remain green. + +--- + +## 15. Physical verification checklist (Frankie) + +APK: `%TEMP%\westcurrent-app-build\outputs\apk\debug\app-debug.apk` + +1. **Save without password** — remote connect, Save on, Remember off → disconnect → Recent pools → password-only prompt → connect +2. **Save remembered password** — Remember on → force-stop → one-tap reconnect without code/password +3. **Rename / remove** — rename persists; remove clears after restart +4. **Safety** — no circuit / heat / light commands during reconnect + +Record any accidental live command honestly. + +--- + +## 16. Known limitations + +- Android Keystore protects the key, not a compromised running app +- No biometric requirement in Phase 1.6 +- No automatic connection on launch +- Saved profiles are **device-local** and intentionally not backed up +- Uninstalling the app or clearing app data removes saved pools +- Moving to another phone requires setting them up again +- Local DHCP recovery relies on user-driven discovery, not background scanning +- StrongBox is optional +- Simulator connections are not saved as pool profiles + +--- + +## 17. Architecture summary + +Small, testable layers: pure Kotlin models + codec, injectable `VaultCipher`, atomic no-backup store, repository with mutex, Android Keystore production cipher, ViewModel orchestration that never stores secrets in UI state, existing `SiteSession` as the only connection engine. From eb95178e5490c1ebbc7fda163ab92ed9b0e8da89 Mon Sep 17 00:00:00 2001 From: "8 West Ventures, LLC" Date: Sun, 12 Jul 2026 23:23:01 -0700 Subject: [PATCH 2/6] Phase 1.6.1: Harden saved-pool vault and connection attempts Remove long-lived decrypted profile caching; zero temporary plaintext byte arrays after each repository call. Replace homemade file writes with Android AtomicFile (SimulatedAtomicVaultFileStore for JVM tests). Introduce ConnectionAttemptResult so save/password/last-success updates bind only to the exact installed attempt. ViewModel request epochs invalidate stale decrypt and password prompts. Pre-install controller identity validation rejects mismatches without publishing site data. Add PasswordSaveDecision, provisional identity upgrade, profile-limit rejection, explicit preferred flag, local Find-on-Wi-Fi recovery, precise Keystore health mapping, and non-saveable system code/address form state. Fable did not implement security or architecture. PR remains draft. --- .../com/pentaircontrol/pool/MainActivity.kt | 16 +- .../savedpools/AndroidKeystoreVaultCipher.kt | 122 ++- .../pool/savedpools/ConnectSaveOptions.kt | 33 +- .../pool/savedpools/SavedPoolCodec.kt | 11 +- .../pool/savedpools/SavedPoolModels.kt | 137 +++- .../pool/savedpools/SavedPoolRepository.kt | 654 ++++++++++------ .../savedpools/SavedPoolRepositoryFactory.kt | 10 +- .../pool/savedpools/VaultCipher.kt | 13 +- .../pool/savedpools/VaultFileStore.kt | 291 ++++++-- .../pool/session/ConnectionAttemptResult.kt | 59 ++ .../pool/session/SiteSession.kt | 270 +++++-- .../java/com/pentaircontrol/pool/ui/AppNav.kt | 19 +- .../pentaircontrol/pool/ui/PoolViewModel.kt | 646 +++++++++++----- .../pool/ui/screens/ConnectScreen.kt | 135 +++- .../pool/ui/screens/SettingsScreen.kt | 34 +- .../pool/savedpools/Phase161HardeningTest.kt | 704 ++++++++++++++++++ .../pool/savedpools/SavedPoolCodecTest.kt | 81 +- .../SavedPoolConnectionBehaviorTest.kt | 193 ++--- .../savedpools/SavedPoolRepositoryTest.kt | 294 +++----- .../SavedPoolSecurityAndBackupTest.kt | 115 +-- .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 335 ++++----- 21 files changed, 2903 insertions(+), 1269 deletions(-) create mode 100644 app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/Phase161HardeningTest.kt diff --git a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt index 24cd0a7..a937e4a 100644 --- a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt +++ b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt @@ -15,7 +15,7 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.pentaircontrol.pool.savedpools.AndroidKeystoreVaultCipher -import com.pentaircontrol.pool.savedpools.AtomicVaultFileStore +import com.pentaircontrol.pool.savedpools.SimulatedAtomicVaultFileStore import com.pentaircontrol.pool.ui.PoolApp import com.pentaircontrol.pool.ui.PoolViewModel import com.pentaircontrol.pool.ui.PoolViewModelFactory @@ -60,6 +60,7 @@ class MainActivity : ComponentActivity() { val savedUnit by vm.savedTemperatureUnit.collectAsStateWithLifecycle() val savedPoolsState by vm.savedPoolsState.collectAsStateWithLifecycle() val passwordPrompt by vm.passwordPrompt.collectAsStateWithLifecycle() + val localRecovery by vm.localRecovery.collectAsStateWithLifecycle() PoolApp( connection = connection, @@ -98,20 +99,27 @@ class MainActivity : ComponentActivity() { savedPoolsState = savedPoolsState, passwordPrompt = passwordPrompt, onConnectSavedPool = vm::connectSavedPool, - onSubmitSavedPassword = { id, pass, remember -> - vm.submitSavedPoolPassword(id, pass, remember) + onSubmitSavedPassword = { id, pass, remember, epoch -> + vm.submitSavedPoolPassword(id, pass, remember, epoch) }, onDismissPasswordPrompt = vm::dismissPasswordPrompt, onRenameSavedPool = vm::renameSavedPool, onDeleteSavedPool = vm::deleteSavedPool, onSaveCurrentPool = { vm.saveCurrentConnectedPool(rememberPassword = false) }, onMarkPreferredSavedPool = vm::markPreferredSavedPool, + onForgetRememberedPassword = vm::forgetRememberedPassword, onRetryVault = vm::refreshSavedPools, onResetVault = vm::resetSavedPoolsVault, + localRecovery = localRecovery, + onStartLocalRecovery = vm::startLocalRecoveryDiscovery, + onConnectRecoveredGateway = { id, gw, pass, remember -> + vm.connectRecoveredGateway(id, gw, pass, remember) + }, + onDismissLocalRecovery = vm::dismissLocalRecovery, // Diagnostics only — no secrets, no "show secret" affordance. vaultDiagnostics = "schema=v1 · alias=${AndroidKeystoreVaultCipher.KEY_ALIAS} · " + - "file=${AtomicVaultFileStore.VAULT_FILE_NAME} · noBackup" + "file=${SimulatedAtomicVaultFileStore.VAULT_FILE_NAME} · noBackup" ) } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt index a5c9b73..22ebd50 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt @@ -2,16 +2,20 @@ package com.pentaircontrol.pool.savedpools import android.os.Build import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo import android.security.keystore.KeyProperties import java.security.KeyStore +import java.security.ProviderException +import java.security.UnrecoverableKeyException import javax.crypto.KeyGenerator import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory /** * AES-256-GCM vault cipher using the Android Keystore. * * Alias: [KEY_ALIAS] - * Does not require StrongBox or user authentication. + * Uses normal Android Keystore placement (StrongBox not blindly attempted). * Key material never leaves the Keystore; never hard-coded. * Keystore operations must not run on the main thread (callers use Dispatchers.IO). */ @@ -21,12 +25,22 @@ class AndroidKeystoreVaultCipher( override fun encrypt(plaintext: ByteArray): ByteArray { val key = getOrCreateKey() - return AesGcmVaultEnvelope.encrypt(key, plaintext) + return try { + AesGcmVaultEnvelope.encrypt(key, plaintext) + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + throw mapCryptoFailure(e, encrypting = true) + } } override fun decrypt(envelope: ByteArray): ByteArray { val key = try { getExistingKey() + } catch (e: UnrecoverableKeyException) { + throw VaultException(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated", e) + } catch (e: ProviderException) { + throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore temporarily unavailable", e) } catch (e: Exception) { throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing", e) } ?: throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing") @@ -35,18 +49,37 @@ class AndroidKeystoreVaultCipher( } catch (e: VaultException) { throw e } catch (e: Exception) { - throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + throw mapCryptoFailure(e, encrypting = false) } } - fun deleteKey() { - try { + /** + * Delete the Keystore alias. Does not swallow errors. + * Verifies the alias is absent after deletion. + */ + fun deleteKey(): KeyDeleteResult { + return try { val ks = KeyStore.getInstance(ANDROID_KEYSTORE) ks.load(null) + if (!ks.containsAlias(keyAlias)) { + return KeyDeleteResult.AlreadyAbsent + } + ks.deleteEntry(keyAlias) + // Verify absence + ks.load(null) if (ks.containsAlias(keyAlias)) { - ks.deleteEntry(keyAlias) + KeyDeleteResult.Failed( + VaultHealth.IO_FAILURE, + "Keystore alias still present after delete" + ) + } else { + KeyDeleteResult.Deleted } - } catch (_: Exception) { + } catch (e: Exception) { + KeyDeleteResult.Failed( + VaultHealth.IO_FAILURE, + "Keystore key deletion failed" + ) } } @@ -58,12 +91,31 @@ class AndroidKeystoreVaultCipher( } } - /** Optional diagnostic only — never log key material. */ + /** + * Diagnostic security level from [KeyInfo] when available. + * Never claims StrongBox unless verified via KeyInfo. + */ fun securityLevelLabel(): String { return try { - val ks = KeyStore.getInstance(ANDROID_KEYSTORE) - ks.load(null) - if (ks.containsAlias(keyAlias)) "AndroidKeyStore" else "missing" + val key = getExistingKey() ?: return "missing" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEYSTORE) + val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + when (info.securityLevel) { + KeyProperties.SECURITY_LEVEL_STRONGBOX -> "StrongBox" + KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE" + KeyProperties.SECURITY_LEVEL_SOFTWARE -> "Software" + else -> "AndroidKeyStore" + } + } + info.isInsideSecureHardware -> "TEE" + else -> "Software" + } + } else { + "AndroidKeyStore" + } } catch (_: Exception) { "unavailable" } @@ -71,24 +123,17 @@ class AndroidKeystoreVaultCipher( private fun getOrCreateKey(): SecretKey { getExistingKey()?.let { return it } - - // Prefer StrongBox when available (API 28+); never require it. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - try { - return generateKey(strongBox = true) - } catch (_: Exception) { - // Fall through to TEE / software Keystore. - } - } - return generateKey(strongBox = false) + // Normal Android Keystore placement — sufficient for this threat model. + // Do not blindly try StrongBox and catch every exception. + return generateKey() } - private fun generateKey(strongBox: Boolean): SecretKey { + private fun generateKey(): SecretKey { val keyGenerator = KeyGenerator.getInstance( KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE ) - val builder = KeyGenParameterSpec.Builder( + val spec = KeyGenParameterSpec.Builder( keyAlias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) @@ -97,10 +142,8 @@ class AndroidKeystoreVaultCipher( .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setRandomizedEncryptionRequired(true) .setUserAuthenticationRequired(false) - if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - builder.setIsStrongBoxBacked(true) - } - keyGenerator.init(builder.build()) + .build() + keyGenerator.init(spec) return keyGenerator.generateKey() } @@ -113,6 +156,31 @@ class AndroidKeystoreVaultCipher( return entry.secretKey } + private fun mapCryptoFailure(e: Exception, encrypting: Boolean): VaultException { + val msg = e.message.orEmpty() + return when { + e is UnrecoverableKeyException || + msg.contains("Key permanently invalidated", ignoreCase = true) || + msg.contains("KeyUserNotAuthenticated", ignoreCase = true) -> + VaultException(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated", e) + e is ProviderException || + msg.contains("KeyStore is not available", ignoreCase = true) -> + VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore unavailable", e) + !encrypting && ( + msg.contains("MAC", ignoreCase = true) || + msg.contains("tag", ignoreCase = true) || + msg.contains("AEADBadTag", ignoreCase = true) + ) -> + VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + else -> + VaultException( + if (encrypting) VaultHealth.IO_FAILURE else VaultHealth.AUTH_TAG_FAILURE, + if (encrypting) "Vault encryption failed" else "Vault authentication failed", + e + ) + } + } + companion object { const val ANDROID_KEYSTORE = "AndroidKeyStore" const val KEY_ALIAS = "westcurrent.saved_pools.aes.v1" diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt index 2823988..0a1f598 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt @@ -21,10 +21,14 @@ class ConnectSaveOptions( class SavedPoolPasswordPrompt( val profileId: SavedPoolId, val displayName: String, - val routeKind: SavedRouteKind + val routeKind: SavedRouteKind, + val replacingFailedStoredPassword: Boolean = false, + /** ViewModel request epoch that created this prompt — stale prompts are ignored. */ + val requestEpoch: Int = 0 ) { override fun toString(): String = - "SavedPoolPasswordPrompt(id=$profileId, name=$displayName, kind=$routeKind)" + "SavedPoolPasswordPrompt(id=$profileId, name=$displayName, kind=$routeKind, " + + "replacing=$replacingFailedStoredPassword, epoch=$requestEpoch)" } /** @@ -37,3 +41,28 @@ sealed class SavedPoolsListState { override fun toString(): String = "Unreadable(health=$health)" } } + +/** + * Non-secret UI state for saved-local address recovery after last-known address fails. + * No background scanning — discovery only after explicit user request. + */ +sealed class LocalRecoveryState { + data object Idle : LocalRecoveryState() + class NeedsDiscovery( + val profileId: SavedPoolId, + val message: String + ) : LocalRecoveryState() { + override fun toString(): String = + "NeedsDiscovery(id=$profileId, message=$message)" + } + class Discovering(val profileId: SavedPoolId) : LocalRecoveryState() { + override fun toString(): String = "Discovering(id=$profileId)" + } + class GatewaysFound( + val profileId: SavedPoolId, + val gateways: List + ) : LocalRecoveryState() { + override fun toString(): String = + "GatewaysFound(id=$profileId, count=${gateways.size})" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt index 7611047..289adfb 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt @@ -9,13 +9,16 @@ import java.nio.charset.StandardCharsets /** * Versioned pure-Kotlin codec for the decrypted saved-pools vault payload. * Rejects unknown versions and truncated/malformed data without excessive allocation. + * + * Schema v1 (PR #3 unmerged — preferred flag included; no released migration required): + * after preferredRoute UTF, write boolean isPreferred. */ object SavedPoolCodec { const val SCHEMA_VERSION: Int = 1 private const val MAGIC = "WCSV" private const val MAX_PROFILES = 50 private const val MAX_STRING = 4_096 - private const val MAX_PAYLOAD = 512_000 + const val MAX_PAYLOAD = 512_000 fun encode(profiles: List): ByteArray { require(profiles.size <= MAX_PROFILES) { "Too many profiles" } @@ -34,6 +37,7 @@ object SavedPoolCodec { out.writeLong(p.createdAtMs) out.writeLong(p.lastSuccessAtMs) out.writeUTF(p.preferredRoute.name) + out.writeBoolean(p.isPreferred) out.writeInt(p.routes.size) for (r in p.routes) { when (r) { @@ -101,6 +105,7 @@ object SavedPoolCodec { val created = inp.readLong() val lastSuccess = inp.readLong() val preferred = SavedRouteKind.valueOf(inp.readUTF()) + val isPreferred = inp.readBoolean() val routeCount = inp.readInt() if (routeCount < 0 || routeCount > 8) { throw VaultException(VaultHealth.CORRUPT, "Invalid route count") @@ -152,11 +157,11 @@ object SavedPoolCodec { createdAtMs = created, lastSuccessAtMs = lastSuccess, preferredRoute = preferred, - routes = routes + routes = routes, + isPreferred = isPreferred ) } if (inp.available() != 0) { - // Trailing bytes are suspicious but not always fatal; reject for safety. throw VaultException(VaultHealth.CORRUPT, "Trailing vault data") } profiles diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt index e7b16d2..f0fb1a9 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt @@ -24,28 +24,44 @@ class SavedPoolSummary( val passwordRequired: Boolean, val readyToConnect: Boolean, val hasLocalRoute: Boolean, - val hasRemoteRoute: Boolean + val hasRemoteRoute: Boolean, + /** Explicit user-preferred recent entry — not derived from last-success time. */ + val isPreferred: Boolean, + /** + * Non-secret password status for Settings: + * REMEMBERED / REQUIRED / NOT_NEEDED + */ + val passwordStatus: PasswordStatus ) { override fun toString(): String = "SavedPoolSummary(id=$id, name=$displayName, route=$routeKindLabel, " + - "passwordRequired=$passwordRequired, ready=$readyToConnect)" + "passwordStatus=$passwordStatus, ready=$readyToConnect, preferred=$isPreferred)" +} + +enum class PasswordStatus { + REMEMBERED, + REQUIRED, + NOT_NEEDED } /** * Internal vault profile. Secrets live only inside [routes] while decrypted in memory. - * Not a data class — [toString] is redacted. + * Not a data class — [toString] is redacted. Prefer [copy] for copy-on-write mutations. */ class SavedPoolProfile( val id: SavedPoolId, - var displayName: String, + val displayName: String, val adapterId: String, - var controllerIdentity: String?, + val controllerIdentity: String?, val createdAtMs: Long, - var lastSuccessAtMs: Long, - var preferredRoute: SavedRouteKind, - var routes: MutableList + val lastSuccessAtMs: Long, + val preferredRoute: SavedRouteKind, + val routes: List, + /** Explicit preferred flag; only successful connections update [lastSuccessAtMs]. */ + val isPreferred: Boolean = false ) { fun passwordIsStored(): Boolean = routes.any { it.hasPassword } + fun passwordRequiredForConnect(kind: SavedRouteKind = preferredRoute): Boolean { val route = routes.find { it.kind == kind } ?: routes.firstOrNull() ?: return true return route.passwordRequired && !route.hasPassword @@ -53,16 +69,60 @@ class SavedPoolProfile( fun readyToConnect(kind: SavedRouteKind = preferredRoute): Boolean { val route = routes.find { it.kind == kind } ?: routes.firstOrNull() ?: return false - return !route.passwordRequired || route.hasPassword + return !route.passwordRequired || route.hasPassword || route.blankPasswordSucceeded } + fun passwordStatus(kind: SavedRouteKind = preferredRoute): PasswordStatus { + val route = routes.find { it.kind == kind } ?: routes.firstOrNull() + ?: return PasswordStatus.REQUIRED + return when { + route.hasPassword -> PasswordStatus.REMEMBERED + route.blankPasswordSucceeded && !route.passwordRequired -> PasswordStatus.NOT_NEEDED + route.passwordRequired -> PasswordStatus.REQUIRED + else -> PasswordStatus.NOT_NEEDED + } + } + + fun copy( + displayName: String = this.displayName, + controllerIdentity: String? = this.controllerIdentity, + lastSuccessAtMs: Long = this.lastSuccessAtMs, + preferredRoute: SavedRouteKind = this.preferredRoute, + routes: List = this.routes, + isPreferred: Boolean = this.isPreferred + ): SavedPoolProfile = SavedPoolProfile( + id = id, + displayName = displayName, + adapterId = adapterId, + controllerIdentity = controllerIdentity, + createdAtMs = createdAtMs, + lastSuccessAtMs = lastSuccessAtMs, + preferredRoute = preferredRoute, + routes = routes, + isPreferred = isPreferred + ) + override fun toString(): String = "SavedPoolProfile(id=$id, name=$displayName, adapter=$adapterId, " + - "routes=${routes.size}, controllerId=${controllerIdentity?.let { "***" }}, secrets=***)" + "routes=${routes.size}, preferred=$isPreferred, controllerId=${controllerIdentity?.let { "***" }}, secrets=***)" } enum class SavedRouteKind { SCREENLOGIC_REMOTE, SCREENLOGIC_LOCAL } +/** + * Explicit password-persistence decision (not a single ambiguous Boolean). + */ +enum class PasswordSaveDecision { + /** New profile: do not store a non-blank password. */ + OMIT_FOR_NEW_PROFILE, + /** Existing profile: keep whatever password is already stored. */ + PRESERVE_EXISTING, + /** Store the provided password after successful authentication. */ + STORE_NEW, + /** Explicitly remove any stored password after successful auth / user action. */ + REMOVE_EXISTING +} + /** * Vendor-owned encrypted route payload. Secrets redacted in [toString]. */ @@ -71,12 +131,13 @@ sealed class SavedRoute { abstract val hasPassword: Boolean /** True when a non-blank password is required for this route and is not stored. */ abstract val passwordRequired: Boolean + abstract val blankPasswordSucceeded: Boolean class ScreenLogicRemote( val systemCode: String, val password: String?, override val passwordRequired: Boolean, - val blankPasswordSucceeded: Boolean + override val blankPasswordSucceeded: Boolean ) : SavedRoute() { override val kind: SavedRouteKind = SavedRouteKind.SCREENLOGIC_REMOTE override val hasPassword: Boolean get() = !password.isNullOrEmpty() @@ -91,7 +152,7 @@ sealed class SavedRoute { val gatewayLabel: String, val password: String?, override val passwordRequired: Boolean, - val blankPasswordSucceeded: Boolean, + override val blankPasswordSucceeded: Boolean, val controllerIdentity: String? ) : SavedRoute() { override val kind: SavedRouteKind = SavedRouteKind.SCREENLOGIC_LOCAL @@ -109,12 +170,24 @@ class SavedPoolDraft( val controllerIdentity: String?, val route: SavedRoute, val preferredRoute: SavedRouteKind, - val rememberPassword: Boolean, + val passwordDecision: PasswordSaveDecision, val timestampMs: Long = System.currentTimeMillis() ) { override fun toString(): String = "SavedPoolDraft(name=$displayName, adapter=$adapterId, route=${route.kind}, " + - "rememberPassword=$rememberPassword, secrets=***)" + "passwordDecision=$passwordDecision, secrets=***)" +} + +sealed class SaveProfileResult { + class Saved(val summary: SavedPoolSummary) : SaveProfileResult() { + override fun toString(): String = "Saved(summary=$summary)" + } + class ProfileLimitReached(val maxProfiles: Int) : SaveProfileResult() { + override fun toString(): String = "ProfileLimitReached(max=$maxProfiles)" + } + class VaultError(val health: VaultHealth) : SaveProfileResult() { + override fun toString(): String = "VaultError(health=$health)" + } } sealed class SavedPoolConnectionResult { @@ -122,17 +195,28 @@ sealed class SavedPoolConnectionResult { val profileId: SavedPoolId, val displayName: String, /** Transient — caller must not put this in StateFlow. */ - val connectionTarget: com.pentaircontrol.pool.domain.AdapterConnectionTarget + val connectionTarget: com.pentaircontrol.pool.domain.AdapterConnectionTarget, + /** Expected controller identity for pre-install validation, if known. */ + val expectedControllerIdentity: String?, + val routeKind: SavedRouteKind, + /** True when a stored password was used (auth failure should prompt replacement). */ + val usedStoredPassword: Boolean ) : SavedPoolConnectionResult() { override fun toString(): String = - "Ready(profileId=$profileId, name=$displayName, target=***)" + "Ready(profileId=$profileId, name=$displayName, target=***, route=$routeKind)" } class PasswordRequired( val profileId: SavedPoolId, val displayName: String, - val routeKind: SavedRouteKind - ) : SavedPoolConnectionResult() + val routeKind: SavedRouteKind, + /** True when a previously stored password failed and needs replacement. */ + val replacingFailedStoredPassword: Boolean = false + ) : SavedPoolConnectionResult() { + override fun toString(): String = + "PasswordRequired(id=$profileId, name=$displayName, kind=$routeKind, " + + "replacing=$replacingFailedStoredPassword)" + } class NotFound(val profileId: SavedPoolId) : SavedPoolConnectionResult() class VaultUnavailable(val health: VaultHealth) : SavedPoolConnectionResult() @@ -161,3 +245,20 @@ class VaultException( ) : Exception(message, cause) { override fun toString(): String = "VaultException(health=$health, message=$message)" } + +/** Result of Keystore key deletion — never swallows failure. */ +sealed class KeyDeleteResult { + data object Deleted : KeyDeleteResult() + data object AlreadyAbsent : KeyDeleteResult() + class Failed(val health: VaultHealth, val message: String) : KeyDeleteResult() { + override fun toString(): String = "Failed(health=$health, message=$message)" + } +} + +/** Result of vault reset — success only when file + key are both gone. */ +sealed class VaultResetResult { + data object Success : VaultResetResult() + class PartialFailure(val health: VaultHealth, val message: String) : VaultResetResult() { + override fun toString(): String = "PartialFailure(health=$health, message=$message)" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt index 2862efb..94e8ac1 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt @@ -9,6 +9,19 @@ import com.pentaircontrol.pool.settings.ControllerTempUnitStore import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +/** + * Encrypted saved-pool repository. + * + * ## Secret lifetime + * Decrypted [SavedPoolProfile] / [SavedRoute] objects and plaintext byte arrays exist only + * for the duration of a single repository call under [mutex]. There is **no** long-lived + * decrypted profile cache. Immutable JVM [String] objects cannot be reliably zeroed; their + * scope is minimized instead. Temporary [ByteArray] plaintext is zeroed in `finally`. + * + * ## Transaction safety + * Mutations use copy-on-write of freshly decoded lists. A failed persist leaves the vault + * and any prior summaries unchanged — no half-mutated in-memory cache. + */ interface SavedPoolRepository { suspend fun health(): VaultHealth suspend fun list(): List @@ -18,28 +31,20 @@ interface SavedPoolRepository { preferredKind: SavedRouteKind? = null ): SavedPoolConnectionResult - /** - * Expected controller identity for a profile, if known. - * Used only to reject a mismatched local reconnect — never as an encryption key. - */ suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? - suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SavedPoolSummary + suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SaveProfileResult suspend fun rename(profileId: SavedPoolId, displayName: String) suspend fun recordSuccessfulConnection(profileId: SavedPoolId, timestampMs: Long) suspend fun delete(profileId: SavedPoolId) - suspend fun updatePasswordAfterSuccess( + suspend fun applyPasswordDecision( profileId: SavedPoolId, kind: SavedRouteKind, password: String?, - rememberPassword: Boolean + decision: PasswordSaveDecision ) - /** Bump last-success so this profile sorts first among recent pools. */ - suspend fun markPreferredRecent(profileId: SavedPoolId, timestampMs: Long = System.currentTimeMillis()) - /** - * After a successful local reconnect via a new address (e.g. DHCP change + discovery), - * update only the local route address/port for [profileId]. - */ + /** Explicit preferred flag — does not alter lastSuccessAtMs. */ + suspend fun setPreferred(profileId: SavedPoolId, preferred: Boolean = true) suspend fun updateLocalAddress( profileId: SavedPoolId, address: String, @@ -47,34 +52,35 @@ interface SavedPoolRepository { gatewayLabel: String, controllerIdentity: String? ) - /** - * Explicit user-confirmed vault wipe: deletes encrypted file and invokes key reset. - * Does not erase appearance or unrelated preferences. - */ - suspend fun resetVault() + suspend fun resetVault(): VaultResetResult } -/** - * Thread-safe repository: encrypt entire profile list on each write. - * Fail-closed: never returns partial secrets on vault failure. - */ class DefaultSavedPoolRepository( private val cipher: VaultCipher, private val store: VaultFileStore, private val preferences: AppPreferencesStore? = null, private val maxProfiles: Int = 40, - /** Called during [resetVault] to delete the Keystore alias (production only). */ - private val onKeyReset: (() -> Unit)? = null + private val onKeyReset: (() -> KeyDeleteResult)? = null ) : SavedPoolRepository { private val mutex = Mutex() - private var cached: List? = null + /** Non-secret diagnostics only — never decrypted routes or credentials. */ + @Volatile private var lastHealth: VaultHealth = VaultHealth.EMPTY + /** + * Test/reflection guard: repository must not declare long-lived secret fields. + * (See SavedPoolNoCacheTest.) + */ + @Suppress("unused") + private fun assertNoSecretCacheFields() = Unit + override suspend fun health(): VaultHealth = mutex.withLock { try { - loadLocked() - lastHealth + withProfiles { profiles -> + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + lastHealth + } } catch (e: VaultException) { lastHealth = e.health e.health @@ -83,9 +89,10 @@ class DefaultSavedPoolRepository( override suspend fun list(): List = mutex.withLock { try { - loadLocked() - .sortedByDescending { it.lastSuccessAtMs } - .map { it.toSummary() } + withProfiles { profiles -> + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + sortSummaries(profiles.map { it.toSummary() }) + } } catch (e: VaultException) { lastHealth = e.health if (e.health == VaultHealth.EMPTY) emptyList() @@ -99,8 +106,9 @@ class DefaultSavedPoolRepository( preferredKind: SavedRouteKind? ): SavedPoolConnectionResult = mutex.withLock { val profiles = try { - loadLocked() + withProfiles { it } } catch (e: VaultException) { + lastHealth = e.health return SavedPoolConnectionResult.VaultUnavailable(e.health) } val profile = profiles.find { it.id == profileId } @@ -108,23 +116,32 @@ class DefaultSavedPoolRepository( val kind = preferredKind ?: profile.preferredRoute val route = profile.routes.find { it.kind == kind } ?: profile.routes.firstOrNull() ?: return SavedPoolConnectionResult.Failure("No connection route saved") + val expectedId = profile.controllerIdentity when (route) { is SavedRoute.ScreenLogicRemote -> { val password = passwordOverride ?: route.password + val usedStored = passwordOverride == null && route.hasPassword if (needsPasswordPrompt(route, password)) { return SavedPoolConnectionResult.PasswordRequired( profileId, profile.displayName, route.kind ) } - val target = ScreenLogicRemoteTarget( - systemCode = route.systemCode, - password = password.orEmpty() + SavedPoolConnectionResult.Ready( + profileId = profileId, + displayName = profile.displayName, + connectionTarget = ScreenLogicRemoteTarget( + systemCode = route.systemCode, + password = password.orEmpty() + ), + expectedControllerIdentity = expectedId, + routeKind = route.kind, + usedStoredPassword = usedStored ) - SavedPoolConnectionResult.Ready(profileId, profile.displayName, target) } is SavedRoute.ScreenLogicLocal -> { val password = passwordOverride ?: route.password + val usedStored = passwordOverride == null && route.hasPassword if (needsPasswordPrompt(route, password)) { return SavedPoolConnectionResult.PasswordRequired( profileId, profile.displayName, route.kind @@ -136,8 +153,14 @@ class DefaultSavedPoolRepository( address = route.address, port = route.port ) - val target = ScreenLogicLocalTarget(gw, password.orEmpty()) - SavedPoolConnectionResult.Ready(profileId, profile.displayName, target) + SavedPoolConnectionResult.Ready( + profileId = profileId, + displayName = profile.displayName, + connectionTarget = ScreenLogicLocalTarget(gw, password.orEmpty()), + expectedControllerIdentity = expectedId ?: route.controllerIdentity, + routeKind = route.kind, + usedStoredPassword = usedStored + ) } } } @@ -145,134 +168,181 @@ class DefaultSavedPoolRepository( override suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? = mutex.withLock { try { - loadLocked().find { it.id == profileId }?.controllerIdentity + withProfiles { it.find { p -> p.id == profileId }?.controllerIdentity } } catch (_: VaultException) { null } } - override suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SavedPoolSummary = + override suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SaveProfileResult = mutex.withLock { val profiles = try { - loadLocked().toMutableList() + withProfiles { it }.toMutableList() } catch (e: VaultException) { if (e.health == VaultHealth.EMPTY) mutableListOf() - else throw e + else { + lastHealth = e.health + return SaveProfileResult.VaultError(e.health) + } } - val route = normalizeRoute(draft.route, draft.rememberPassword) val identity = draft.controllerIdentity?.takeIf { it.isNotBlank() } + val existingIdx = findMergeIndex(profiles, draft, identity) + + if (existingIdx >= 0) { + val existing = profiles[existingIdx] + val mergedRoute = applyPasswordDecisionToRoute( + newRoute = draft.route, + existing = existing.routes.find { it.kind == draft.route.kind }, + decision = draft.passwordDecision + ) + val newRoutes = existing.routes.toMutableList() + val rIdx = newRoutes.indexOfFirst { it.kind == mergedRoute.kind } + if (rIdx >= 0) newRoutes[rIdx] = mergedRoute else newRoutes += mergedRoute + profiles[existingIdx] = existing.copy( + controllerIdentity = identity ?: existing.controllerIdentity, + lastSuccessAtMs = draft.timestampMs, + preferredRoute = draft.preferredRoute, + routes = newRoutes + ) + try { + persistProfiles(profiles) + } catch (e: VaultException) { + lastHealth = e.health + return SaveProfileResult.VaultError(e.health) + } + return SaveProfileResult.Saved(profiles[existingIdx].toSummary()) + } - val existing = when { - identity != null -> - profiles.find { - it.controllerIdentity == identity && it.adapterId == draft.adapterId - } - else -> null + if (profiles.size >= maxProfiles) { + return SaveProfileResult.ProfileLimitReached(maxProfiles) } - val profile: SavedPoolProfile - if (existing != null) { - profile = existing - // Never overwrite a user-facing name on merge; only routes / times update. - mergeRoute(profile, route) - if (identity != null) profile.controllerIdentity = identity - profile.lastSuccessAtMs = draft.timestampMs - profile.preferredRoute = draft.preferredRoute - } else { - if (profiles.size >= maxProfiles) { - // Drop oldest by last success - profiles.sortBy { it.lastSuccessAtMs } - profiles.removeAt(0) + val route = applyPasswordDecisionToRoute( + newRoute = draft.route, + existing = null, + decision = when (draft.passwordDecision) { + PasswordSaveDecision.PRESERVE_EXISTING -> + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + else -> draft.passwordDecision } - profile = SavedPoolProfile( - id = SavedPoolId.generate(), - displayName = draft.displayName.ifBlank { "Saved pool" }, - adapterId = draft.adapterId, - controllerIdentity = identity, - createdAtMs = draft.timestampMs, - lastSuccessAtMs = draft.timestampMs, - preferredRoute = draft.preferredRoute, - routes = mutableListOf(route) - ) - profiles += profile + ) + val profile = SavedPoolProfile( + id = SavedPoolId.generate(), + displayName = draft.displayName.ifBlank { "Saved pool" }, + adapterId = draft.adapterId, + controllerIdentity = identity, + createdAtMs = draft.timestampMs, + lastSuccessAtMs = draft.timestampMs, + preferredRoute = draft.preferredRoute, + routes = listOf(route), + isPreferred = false + ) + profiles += profile + try { + persistProfiles(profiles) + } catch (e: VaultException) { + lastHealth = e.health + return SaveProfileResult.VaultError(e.health) } - persistLocked(profiles) - profile.toSummary() + SaveProfileResult.Saved(profile.toSummary()) } override suspend fun rename(profileId: SavedPoolId, displayName: String) = mutex.withLock { val name = displayName.trim() require(name.isNotEmpty()) { "Name required" } - val profiles = loadLocked().toMutableList() - val p = profiles.find { it.id == profileId } ?: return@withLock - p.displayName = name - // mark as user-renamed via preference flag is optional; name is enough - persistLocked(profiles) + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } + if (idx < 0) return@withLock + profiles[idx] = profiles[idx].copy(displayName = name) + persistProfiles(profiles) } override suspend fun recordSuccessfulConnection(profileId: SavedPoolId, timestampMs: Long) = mutex.withLock { - val profiles = loadLocked().toMutableList() - val p = profiles.find { it.id == profileId } ?: return@withLock - p.lastSuccessAtMs = timestampMs - persistLocked(profiles) + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } + if (idx < 0) return@withLock + profiles[idx] = profiles[idx].copy(lastSuccessAtMs = timestampMs) + persistProfiles(profiles) } override suspend fun delete(profileId: SavedPoolId) = mutex.withLock { val profiles = try { - loadLocked().toMutableList() + withProfiles { it }.toMutableList() } catch (e: VaultException) { if (e.health == VaultHealth.EMPTY) return@withLock throw e } - val removed = profiles.find { it.id == profileId } + val removed = profiles.find { it.id == profileId } ?: return@withLock profiles.removeAll { it.id == profileId } if (profiles.isEmpty()) { store.delete() - cached = emptyList() lastHealth = VaultHealth.EMPTY } else { - persistLocked(profiles) + persistProfiles(profiles) } - // Clear per-controller temp unit when identity known - val identity = removed?.controllerIdentity + // Clear controller temp unit only if no remaining profile shares the identity. + val identity = removed.controllerIdentity if (identity != null && preferences != null) { - ControllerTempUnitStore.clear(preferences, identity) + val stillReferenced = profiles.any { it.controllerIdentity == identity } + if (!stillReferenced) { + ControllerTempUnitStore.clear(preferences, identity) + } } } - override suspend fun updatePasswordAfterSuccess( + override suspend fun applyPasswordDecision( profileId: SavedPoolId, kind: SavedRouteKind, password: String?, - rememberPassword: Boolean + decision: PasswordSaveDecision ) = mutex.withLock { - val profiles = loadLocked().toMutableList() - val p = profiles.find { it.id == profileId } ?: return@withLock - val idx = p.routes.indexOfFirst { it.kind == kind } + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } if (idx < 0) return@withLock - when (val r = p.routes[idx]) { - is SavedRoute.ScreenLogicRemote -> { - p.routes[idx] = r.copyRoute( - password = if (rememberPassword) password else null, - passwordRequired = !password.isNullOrEmpty() && !rememberPassword, - blankPasswordSucceeded = password.isNullOrEmpty() - ) - } - is SavedRoute.ScreenLogicLocal -> { - p.routes[idx] = r.copyRoute( - password = if (rememberPassword) password else null, - passwordRequired = !password.isNullOrEmpty() && !rememberPassword, - blankPasswordSucceeded = password.isNullOrEmpty() - ) - } - } - persistLocked(profiles) + val p = profiles[idx] + val rIdx = p.routes.indexOfFirst { it.kind == kind } + if (rIdx < 0) return@withLock + val existing = p.routes[rIdx] + val updated = applyPasswordDecisionToRoute( + newRoute = when (existing) { + is SavedRoute.ScreenLogicRemote -> + SavedRoute.ScreenLogicRemote( + existing.systemCode, password, existing.passwordRequired, + existing.blankPasswordSucceeded + ) + is SavedRoute.ScreenLogicLocal -> + SavedRoute.ScreenLogicLocal( + existing.address, existing.port, existing.gatewayLabel, + password, existing.passwordRequired, existing.blankPasswordSucceeded, + existing.controllerIdentity + ) + }, + existing = existing, + decision = decision + ) + val newRoutes = p.routes.toMutableList() + newRoutes[rIdx] = updated + profiles[idx] = p.copy(routes = newRoutes) + persistProfiles(profiles) } - override suspend fun markPreferredRecent(profileId: SavedPoolId, timestampMs: Long) = - recordSuccessfulConnection(profileId, timestampMs) + override suspend fun setPreferred(profileId: SavedPoolId, preferred: Boolean) = + mutex.withLock { + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } + if (idx < 0) return@withLock + // Single preferred: clear others when setting preferred=true + for (i in profiles.indices) { + val p = profiles[i] + val should = if (p.id == profileId) preferred else false + if (p.isPreferred != should) { + profiles[i] = p.copy(isPreferred = should) + } + } + persistProfiles(profiles) + } override suspend fun updateLocalAddress( profileId: SavedPoolId, @@ -281,17 +351,22 @@ class DefaultSavedPoolRepository( gatewayLabel: String, controllerIdentity: String? ) = mutex.withLock { - val profiles = loadLocked().toMutableList() - val p = profiles.find { it.id == profileId } ?: return@withLock - val idx = p.routes.indexOfFirst { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL } + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } if (idx < 0) return@withLock - val existing = p.routes[idx] as SavedRoute.ScreenLogicLocal - // Conservative: if both identities are known and conflict, refuse the update. + val p = profiles[idx] + val rIdx = p.routes.indexOfFirst { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL } + if (rIdx < 0) return@withLock + val existing = p.routes[rIdx] as SavedRoute.ScreenLogicLocal val savedId = existing.controllerIdentity ?: p.controllerIdentity if (savedId != null && controllerIdentity != null && savedId != controllerIdentity) { return@withLock } - p.routes[idx] = SavedRoute.ScreenLogicLocal( + // When expected identity unavailable, refuse silent replace with different device + // unless address/port update is for the same provisional route after user selection + // that already passed SiteSession identity validation (controllerIdentity may still be null). + val newRoutes = p.routes.toMutableList() + newRoutes[rIdx] = SavedRoute.ScreenLogicLocal( address = address.trim(), port = port, gatewayLabel = gatewayLabel, @@ -300,132 +375,240 @@ class DefaultSavedPoolRepository( blankPasswordSucceeded = existing.blankPasswordSucceeded, controllerIdentity = controllerIdentity ?: existing.controllerIdentity ) - if (controllerIdentity != null) p.controllerIdentity = controllerIdentity - persistLocked(profiles) + profiles[idx] = p.copy( + routes = newRoutes, + controllerIdentity = controllerIdentity ?: p.controllerIdentity + ) + persistProfiles(profiles) } - override suspend fun resetVault() = mutex.withLock { - store.delete() + override suspend fun resetVault(): VaultResetResult = mutex.withLock { try { - onKeyReset?.invoke() - } catch (_: Exception) { + if (store.exists()) { + store.delete() + } + } catch (e: VaultException) { + lastHealth = e.health + return VaultResetResult.PartialFailure(e.health, "Vault file could not be removed") } - cached = emptyList() - lastHealth = VaultHealth.EMPTY - } - - private fun needsPasswordPrompt(route: SavedRoute, password: String?): Boolean { - if (!password.isNullOrEmpty()) return false - return when (route) { - is SavedRoute.ScreenLogicRemote -> - route.passwordRequired && !route.blankPasswordSucceeded - is SavedRoute.ScreenLogicLocal -> - route.passwordRequired && !route.blankPasswordSucceeded + val keyResult = try { + onKeyReset?.invoke() ?: KeyDeleteResult.AlreadyAbsent + } catch (e: Exception) { + lastHealth = VaultHealth.IO_FAILURE + return VaultResetResult.PartialFailure( + VaultHealth.IO_FAILURE, + "Keystore key removal failed" + ) } - } - - private fun normalizeRoute(route: SavedRoute, rememberPassword: Boolean): SavedRoute { - return when (route) { - is SavedRoute.ScreenLogicRemote -> { - val pw = route.password - val blank = pw.isNullOrEmpty() - SavedRoute.ScreenLogicRemote( - systemCode = route.systemCode, - password = if (rememberPassword && !blank) pw else null, - passwordRequired = !blank && !rememberPassword, - blankPasswordSucceeded = blank - ) - } - is SavedRoute.ScreenLogicLocal -> { - val pw = route.password - val blank = pw.isNullOrEmpty() - SavedRoute.ScreenLogicLocal( - address = route.address, - port = route.port, - gatewayLabel = route.gatewayLabel, - password = if (rememberPassword && !blank) pw else null, - passwordRequired = !blank && !rememberPassword, - blankPasswordSucceeded = blank, - controllerIdentity = route.controllerIdentity + when (keyResult) { + is KeyDeleteResult.Failed -> { + lastHealth = keyResult.health + return VaultResetResult.PartialFailure( + keyResult.health, + "Keystore key removal failed" ) } + KeyDeleteResult.Deleted, KeyDeleteResult.AlreadyAbsent -> Unit } + lastHealth = VaultHealth.EMPTY + VaultResetResult.Success } - private fun mergeRoute(profile: SavedPoolProfile, route: SavedRoute) { - val idx = profile.routes.indexOfFirst { it.kind == route.kind } - if (idx >= 0) { - profile.routes[idx] = route - } else { - profile.routes += route - } - } - - private fun SavedRoute.ScreenLogicRemote.copyRoute( - password: String?, - passwordRequired: Boolean, - blankPasswordSucceeded: Boolean - ) = SavedRoute.ScreenLogicRemote( - systemCode = systemCode, - password = password, - passwordRequired = passwordRequired, - blankPasswordSucceeded = blankPasswordSucceeded - ) - - private fun SavedRoute.ScreenLogicLocal.copyRoute( - password: String?, - passwordRequired: Boolean, - blankPasswordSucceeded: Boolean - ) = SavedRoute.ScreenLogicLocal( - address = address, - port = port, - gatewayLabel = gatewayLabel, - password = password, - passwordRequired = passwordRequired, - blankPasswordSucceeded = blankPasswordSucceeded, - controllerIdentity = controllerIdentity - ) + // ——— internals ——— - private fun loadLocked(): List { - cached?.let { return it } + /** + * Decrypt → operate → zero plaintext. Never caches decrypted profiles. + * JVM String fields cannot be zeroed; minimize scope. + */ + private inline fun withProfiles(block: (List) -> T): T { if (!store.exists()) { lastHealth = VaultHealth.EMPTY - cached = emptyList() - return emptyList() + return block(emptyList()) } val envelope = store.read() ?: run { lastHealth = VaultHealth.EMPTY - cached = emptyList() - return emptyList() + return block(emptyList()) } - if (envelope.isEmpty()) { - lastHealth = VaultHealth.EMPTY - cached = emptyList() - return emptyList() - } - val plain = try { - cipher.decrypt(envelope) + var plain: ByteArray? = null + return try { + plain = cipher.decrypt(envelope) + val profiles = SavedPoolCodec.decode(plain) + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + block(profiles) } catch (e: VaultException) { lastHealth = e.health throw e } catch (e: Exception) { lastHealth = VaultHealth.AUTH_TAG_FAILURE throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Decrypt failed", e) + } finally { + plain?.fill(0) + } + } + + private fun persistProfiles(profiles: List) { + var plain: ByteArray? = null + try { + plain = SavedPoolCodec.encode(profiles) + val envelope = cipher.encrypt(plain) + store.writeAtomic(envelope) + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + } finally { + plain?.fill(0) + } + } + + private fun findMergeIndex( + profiles: List, + draft: SavedPoolDraft, + identity: String? + ): Int { + // 1. Prefer exact adapter + controller identity match + if (identity != null) { + val byId = profiles.indexOfFirst { + it.controllerIdentity == identity && it.adapterId == draft.adapterId + } + if (byId >= 0) return byId + // 2. Upgrade provisional exact-route match (identity null) + val provisional = profiles.indexOfFirst { p -> + p.adapterId == draft.adapterId && + p.controllerIdentity == null && + exactRouteMatch(p, draft.route) + } + if (provisional >= 0) return provisional + } else { + // No identity on new connection: exact route match only for provisional merge + val exact = profiles.indexOfFirst { p -> + p.adapterId == draft.adapterId && exactRouteMatch(p, draft.route) + } + if (exact >= 0) return exact + } + return -1 + } + + private fun exactRouteMatch(profile: SavedPoolProfile, route: SavedRoute): Boolean { + return when (route) { + is SavedRoute.ScreenLogicRemote -> { + val code = normalizeSystemCode(route.systemCode) + profile.routes.any { r -> + r is SavedRoute.ScreenLogicRemote && + normalizeSystemCode(r.systemCode) == code + } + } + is SavedRoute.ScreenLogicLocal -> { + profile.routes.any { r -> + r is SavedRoute.ScreenLogicLocal && + r.address.trim() == route.address.trim() && + r.port == route.port + } + } } - val profiles = SavedPoolCodec.decode(plain) - lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE - cached = profiles - return profiles } - private fun persistLocked(profiles: List) { - val plain = SavedPoolCodec.encode(profiles) - val envelope = cipher.encrypt(plain) - store.writeAtomic(envelope) - cached = profiles.toList() - lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + private fun normalizeSystemCode(code: String): String = + code.trim().uppercase().replace("-", "").replace(" ", "") + + private fun needsPasswordPrompt(route: SavedRoute, password: String?): Boolean { + if (!password.isNullOrEmpty()) return false + if (route.blankPasswordSucceeded && !route.passwordRequired) return false + return route.passwordRequired || (!route.hasPassword && !route.blankPasswordSucceeded && route.passwordRequired) + } + + private fun applyPasswordDecisionToRoute( + newRoute: SavedRoute, + existing: SavedRoute?, + decision: PasswordSaveDecision + ): SavedRoute { + return when (newRoute) { + is SavedRoute.ScreenLogicRemote -> { + val (pw, required, blankOk) = resolvePassword( + incoming = newRoute.password, + existingPassword = (existing as? SavedRoute.ScreenLogicRemote)?.password, + existingRequired = existing?.passwordRequired, + existingBlank = existing?.blankPasswordSucceeded, + decision = decision + ) + SavedRoute.ScreenLogicRemote( + systemCode = newRoute.systemCode.trim(), + password = pw, + passwordRequired = required, + blankPasswordSucceeded = blankOk + ) + } + is SavedRoute.ScreenLogicLocal -> { + val (pw, required, blankOk) = resolvePassword( + incoming = newRoute.password, + existingPassword = (existing as? SavedRoute.ScreenLogicLocal)?.password, + existingRequired = existing?.passwordRequired, + existingBlank = existing?.blankPasswordSucceeded, + decision = decision + ) + SavedRoute.ScreenLogicLocal( + address = newRoute.address.trim(), + port = newRoute.port, + gatewayLabel = newRoute.gatewayLabel, + password = pw, + passwordRequired = required, + blankPasswordSucceeded = blankOk, + controllerIdentity = newRoute.controllerIdentity + ?: (existing as? SavedRoute.ScreenLogicLocal)?.controllerIdentity + ) + } + } + } + + private fun resolvePassword( + incoming: String?, + existingPassword: String?, + existingRequired: Boolean?, + existingBlank: Boolean?, + decision: PasswordSaveDecision + ): Triple { + val blankIncoming = incoming.isNullOrEmpty() + return when (decision) { + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE -> { + Triple( + null, + !blankIncoming, + blankIncoming + ) + } + PasswordSaveDecision.PRESERVE_EXISTING -> { + if (existingPassword != null) { + Triple(existingPassword, false, false) + } else if (existingBlank == true) { + Triple(null, false, true) + } else { + Triple( + null, + existingRequired ?: !blankIncoming, + blankIncoming && (existingBlank ?: true) + ) + } + } + PasswordSaveDecision.STORE_NEW -> { + if (blankIncoming) Triple(null, false, true) + else Triple(incoming, false, false) + } + PasswordSaveDecision.REMOVE_EXISTING -> { + Triple( + null, + // After removing a known-bad password, future connects should prompt + // if a non-blank password was involved. + true, + false + ) + } + } } + + private fun sortSummaries(list: List): List = + list.sortedWith( + compareByDescending { it.isPreferred } + .thenByDescending { it.lastSuccessAtMs } + .thenBy { it.id.value } + ) } fun SavedPoolProfile.toSummary(): SavedPoolSummary { @@ -435,34 +618,28 @@ fun SavedPoolProfile.toSummary(): SavedPoolSummary { SavedRouteKind.SCREENLOGIC_LOCAL -> "Home Wi‑Fi" null -> "Saved" } - val passwordRequired = passwordRequiredForConnect() return SavedPoolSummary( id = id, displayName = displayName, adapterId = adapterId, routeKindLabel = kindLabel, lastSuccessAtMs = lastSuccessAtMs, - passwordRequired = passwordRequired, + passwordRequired = passwordRequiredForConnect(), readyToConnect = readyToConnect(), hasLocalRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL }, - hasRemoteRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE } + hasRemoteRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE }, + isPreferred = isPreferred, + passwordStatus = passwordStatus() ) } -/** - * Helpers to build drafts from successful ScreenLogic connections. - * - * Password is kept on the draft route until [DefaultSavedPoolRepository] normalizes it - * via [ConnectSaveOptions]-equivalent [SavedPoolDraft.rememberPassword]. That way a - * non-blank password with remember=false still records passwordRequired correctly. - */ object SavedPoolDraftFactory { fun fromRemote( systemCode: String, password: String, displayName: String, controllerIdentity: String?, - savePassword: Boolean, + passwordDecision: PasswordSaveDecision, timestampMs: Long = System.currentTimeMillis() ): SavedPoolDraft { val blank = password.isEmpty() @@ -472,13 +649,12 @@ object SavedPoolDraftFactory { controllerIdentity = controllerIdentity, route = SavedRoute.ScreenLogicRemote( systemCode = systemCode.trim(), - // Keep password for normalizeRoute; repository strips when not remembered. password = if (blank) null else password, - passwordRequired = !blank && !savePassword, + passwordRequired = !blank, blankPasswordSucceeded = blank ), preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, - rememberPassword = savePassword, + passwordDecision = passwordDecision, timestampMs = timestampMs ) } @@ -487,7 +663,7 @@ object SavedPoolDraftFactory { gateway: DiscoveredGateway, password: String, controllerIdentity: String?, - savePassword: Boolean, + passwordDecision: PasswordSaveDecision, displayName: String? = null, timestampMs: Long = System.currentTimeMillis() ): SavedPoolDraft { @@ -501,13 +677,21 @@ object SavedPoolDraftFactory { port = gateway.port, gatewayLabel = gateway.displayName, password = if (blank) null else password, - passwordRequired = !blank && !savePassword, + passwordRequired = !blank, blankPasswordSucceeded = blank, controllerIdentity = controllerIdentity ), preferredRoute = SavedRouteKind.SCREENLOGIC_LOCAL, - rememberPassword = savePassword, + passwordDecision = passwordDecision, timestampMs = timestampMs ) } + + /** Map legacy Boolean consent to explicit decision for a new profile. */ + fun decisionForNewProfile(rememberPassword: Boolean, passwordBlank: Boolean): PasswordSaveDecision = + when { + passwordBlank -> PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + rememberPassword -> PasswordSaveDecision.STORE_NEW + else -> PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt index 845e444..00feef3 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt @@ -8,7 +8,8 @@ import kotlinx.coroutines.withContext /** * Production wiring for the encrypted saved-pools vault. * - * Vault path: [Context.getNoBackupFilesDir] / [AtomicVaultFileStore.VAULT_FILE_NAME] + * Vault path: [Context.getNoBackupFilesDir] / [SimulatedAtomicVaultFileStore.VAULT_FILE_NAME] + * Store: [AndroidAtomicVaultFileStore] (platform AtomicFile) * Cipher: Android Keystore AES-256-GCM, alias [AndroidKeystoreVaultCipher.KEY_ALIAS] */ object SavedPoolRepositoryFactory { @@ -19,7 +20,7 @@ object SavedPoolRepositoryFactory { ): ProductionSavedPools { val appContext = context.applicationContext val cipher = AndroidKeystoreVaultCipher() - val store = AtomicVaultFileStore(appContext.noBackupFilesDir) + val store = AndroidAtomicVaultFileStore(appContext.noBackupFilesDir) val repository = DefaultSavedPoolRepository( cipher = cipher, store = store, @@ -33,7 +34,6 @@ object SavedPoolRepositoryFactory { ) } - /** Suspend helpers that force IO dispatcher for Keystore / disk. */ suspend fun onIo(block: suspend () -> T): T = withContext(Dispatchers.IO) { block() } } @@ -41,10 +41,10 @@ object SavedPoolRepositoryFactory { class ProductionSavedPools( val repository: SavedPoolRepository, val cipher: AndroidKeystoreVaultCipher, - val store: AtomicVaultFileStore + val store: AndroidAtomicVaultFileStore ) { fun diagnosticsVaultPathHint(): String = - "noBackupFilesDir/${AtomicVaultFileStore.VAULT_FILE_NAME}" + "noBackupFilesDir/${SimulatedAtomicVaultFileStore.VAULT_FILE_NAME}" fun diagnosticsKeyAlias(): String = AndroidKeystoreVaultCipher.KEY_ALIAS diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt index 7723f41..3b00580 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt @@ -77,9 +77,18 @@ object AesGcmVaultEnvelope { buf.get(iv) val ciphertext = ByteArray(buf.remaining()) buf.get(ciphertext) - return try { - val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val cipher = try { + Cipher.getInstance("AES/GCM/NoPadding") + } catch (e: Exception) { + throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Cipher unavailable", e) + } + try { cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + } catch (e: Exception) { + // Cipher.init failures are not authentication-tag failures. + throw VaultException(VaultHealth.KEY_INVALIDATED, "Cipher init failed", e) + } + return try { cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) cipher.doFinal(ciphertext) } catch (e: Exception) { diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt index 6b6174d..01ca8b2 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt @@ -1,28 +1,59 @@ package com.pentaircontrol.pool.savedpools import java.io.File +import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger -/** Atomic encrypted blob store. */ +/** + * Encrypted vault blob store. + * + * Production Android uses [AndroidAtomicVaultFileStore] backed by [android.util.AtomicFile]. + * JVM unit tests use [InMemoryVaultFileStore] or [SimulatedAtomicVaultFileStore] + * (AtomicFile protocol without Android framework). + */ interface VaultFileStore { + /** + * Read encrypted envelope, or null if no vault exists (EMPTY). + * Zero-length existing vault is CORRUPT, not EMPTY. + * Oversized files are rejected before full allocation. + */ fun read(): ByteArray? + fun writeAtomic(envelope: ByteArray) + + /** + * Delete vault base/new files. Must throw [VaultException] on failure + * rather than silently reporting success. + */ fun delete() + fun exists(): Boolean } -/** - * In-memory store for unit tests. - */ +/** In-memory store for unit tests. */ class InMemoryVaultFileStore : VaultFileStore { @Volatile private var bytes: ByteArray? = null - override fun read(): ByteArray? = bytes?.copyOf() + override fun read(): ByteArray? { + val b = bytes ?: return null + if (b.isEmpty()) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + if (b.size > MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } + return b.copyOf() + } override fun writeAtomic(envelope: ByteArray) { + if (envelope.size > MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Envelope exceeds size limit") + } bytes = envelope.copyOf() } @@ -31,83 +62,223 @@ class InMemoryVaultFileStore : VaultFileStore { } override fun exists(): Boolean = bytes != null + + /** Test seam: corrupt in place without going through writeAtomic. */ + fun corruptInPlace(data: ByteArray) { + bytes = data.copyOf() + } + + companion object { + const val MAX_ENVELOPE_BYTES: Int = 600_000 + } } /** - * Atomic file store under a provided directory (production: [android.content.Context.getNoBackupFilesDir]). + * Pure-Kotlin simulation of Android [android.util.AtomicFile] for JVM tests. * - * Write pattern: encrypt already done by caller → write temp → flush/sync → rename over prior file. - * A crash leaves either the prior valid vault or the new valid vault, not a half-written chimera. + * Protocol: + * - Base file holds committed content + * - Write opens `name.new`, writes fully, then renames to base (commit) + * - failWrite deletes the `.new` file and leaves base intact + * - openRead prefers base; recovers if only `.new` exists from interrupted prior write */ -class AtomicVaultFileStore( +class SimulatedAtomicVaultFileStore( private val directory: File, - private val fileName: String = VAULT_FILE_NAME + private val fileName: String = VAULT_FILE_NAME, + private val fault: FaultInjection = FaultInjection() ) : VaultFileStore { - private val file = File(directory, fileName) - private val temp = File(directory, "$fileName.tmp") - private val backup = File(directory, "$fileName.bak") + + private val base = File(directory, fileName) + private val neu = File(directory, "$fileName.new") + + class FaultInjection { + @Volatile var failBeforeCommit: Boolean = false + @Volatile var failWhileWriting: Boolean = false + @Volatile var failDelete: Boolean = false + @Volatile var cancelBeforeCommit: Boolean = false + val writeAttempts = AtomicInteger(0) + val commits = AtomicInteger(0) + } + + override fun exists(): Boolean = base.exists() || neu.exists() override fun read(): ByteArray? { - if (!file.exists()) return null + // AtomicFile-style: if base missing but .new exists, treat carefully. + if (!base.exists() && !neu.exists()) return null + val source = when { + base.exists() -> base + neu.exists() -> neu // interrupted prior write: AtomicFile openRead may use leftover + else -> return null + } + val size = source.length() + if (size == 0L && source.exists()) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + if (size > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } return try { - file.readBytes() - } catch (e: IOException) { + FileInputStream(source).use { it.readBytes() }.also { bytes -> + if (bytes.isEmpty()) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + } + } catch (e: VaultException) { + throw e + } catch (e: Exception) { throw VaultException(VaultHealth.IO_FAILURE, "Failed to read vault", e) } } override fun writeAtomic(envelope: ByteArray) { + if (envelope.size > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Envelope exceeds size limit") + } + fault.writeAttempts.incrementAndGet() try { if (!directory.exists() && !directory.mkdirs()) { throw IOException("Cannot create vault directory") } - // Remove stale temp/backup from a previous crash. - if (temp.exists()) temp.delete() - if (backup.exists()) backup.delete() - - FileOutputStream(temp).use { fos -> + if (fault.failBeforeCommit) { + // Simulate crash before any durable write of .new + throw IOException("injected failure before commit") + } + FileOutputStream(neu).use { fos -> + if (fault.failWhileWriting) { + fos.write(envelope, 0, minOf(4, envelope.size)) + throw IOException("injected failure while writing") + } fos.write(envelope) fos.flush() try { fos.fd.sync() } catch (_: Exception) { - // Best-effort fsync; still proceed with rename. } } + if (fault.cancelBeforeCommit) { + // Leave .new in place; do not rename — mimics cancelled finishWrite + throw IOException("injected cancel before commit") + } + // Commit: replace base with .new (AtomicFile.finishWrite) + if (base.exists() && !base.delete()) { + throw IOException("Cannot replace base vault") + } + if (!neu.renameTo(base)) { + // copy fallback only for .new → base on Windows; never writeBytes over live base first + FileOutputStream(base).use { it.write(neu.readBytes()) } + neu.delete() + } + fault.commits.incrementAndGet() + } catch (e: VaultException) { + failWriteCleanup() + throw e + } catch (e: Exception) { + failWriteCleanup() + throw VaultException(VaultHealth.IO_FAILURE, "Failed to write vault", e) + } + } + + private fun failWriteCleanup() { + try { + if (neu.exists()) neu.delete() + } catch (_: Exception) { + } + } + + override fun delete() { + try { + if (fault.failDelete) { + throw IOException("injected delete failure") + } + val baseDeleted = !base.exists() || base.delete() + val newDeleted = !neu.exists() || neu.delete() + if (!baseDeleted || !newDeleted) { + throw VaultException(VaultHealth.IO_FAILURE, "Failed to delete vault files") + } + if (base.exists() || neu.exists()) { + throw VaultException(VaultHealth.IO_FAILURE, "Vault files still present after delete") + } + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + throw VaultException(VaultHealth.IO_FAILURE, "Failed to delete vault", e) + } + } - if (file.exists()) { - if (!file.renameTo(backup)) { - // Windows / some Android paths: delete destination then rename. - if (!file.delete() || !temp.renameTo(file)) { - // Last resort copy - file.writeBytes(temp.readBytes()) - temp.delete() - } else { - backup.delete() - return - } - } else { - if (!temp.renameTo(file)) { - // Restore prior vault - backup.renameTo(file) - throw IOException("Atomic rename failed") - } - backup.delete() + fun baseFile(): File = base + fun newFile(): File = neu + + companion object { + const val VAULT_FILE_NAME = "saved_pools_v1.enc" + } +} + +/** + * Production vault store using Android [android.util.AtomicFile]. + * + * - [android.util.AtomicFile.startWrite] / write complete envelope / [android.util.AtomicFile.finishWrite] + * - [android.util.AtomicFile.failWrite] on any failure + * - No [File.writeBytes] fallback over the live vault path + */ +class AndroidAtomicVaultFileStore( + private val directory: File, + private val fileName: String = SimulatedAtomicVaultFileStore.VAULT_FILE_NAME +) : VaultFileStore { + private val atomicFile: android.util.AtomicFile by lazy { + if (!directory.exists()) directory.mkdirs() + android.util.AtomicFile(File(directory, fileName)) + } + + override fun exists(): Boolean = atomicFile.baseFile.exists() + + override fun read(): ByteArray? { + val base = atomicFile.baseFile + if (!base.exists()) return null + val size = base.length() + if (size == 0L) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + if (size > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } + return try { + atomicFile.openRead().use { input -> + val out = ByteArray(size.toInt()) + var off = 0 + while (off < out.size) { + val n = input.read(out, off, out.size - off) + if (n < 0) break + off += n } - } else { - if (!temp.renameTo(file)) { - file.writeBytes(temp.readBytes()) - temp.delete() + if (off == 0) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") } + if (off != out.size) out.copyOf(off) else out } - if (temp.exists()) temp.delete() - if (backup.exists()) backup.delete() } catch (e: VaultException) { throw e } catch (e: Exception) { - try { - temp.delete() - } catch (_: Exception) { + throw VaultException(VaultHealth.IO_FAILURE, "Failed to read vault", e) + } + } + + override fun writeAtomic(envelope: ByteArray) { + if (envelope.size > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Envelope exceeds size limit") + } + var stream: FileOutputStream? = null + try { + stream = atomicFile.startWrite() + stream.write(envelope) + stream.flush() + atomicFile.finishWrite(stream) + stream = null + } catch (e: Exception) { + if (stream != null) { + try { + atomicFile.failWrite(stream) + } catch (_: Exception) { + } } throw VaultException(VaultHealth.IO_FAILURE, "Failed to write vault", e) } @@ -115,24 +286,19 @@ class AtomicVaultFileStore( override fun delete() { try { - if (file.exists()) file.delete() - if (temp.exists()) temp.delete() - if (backup.exists()) backup.delete() + atomicFile.delete() + if (atomicFile.baseFile.exists()) { + throw VaultException(VaultHealth.IO_FAILURE, "Vault base still present after delete") + } + } catch (e: VaultException) { + throw e } catch (e: Exception) { throw VaultException(VaultHealth.IO_FAILURE, "Failed to delete vault", e) } } - override fun exists(): Boolean = file.exists() - - /** Diagnostics only — path string, not secret contents. */ - fun filePathForDiagnostics(): String = file.absolutePath - + fun filePathForDiagnostics(): String = atomicFile.baseFile.absolutePath fun directoryForDiagnostics(): String = directory.absolutePath - - companion object { - const val VAULT_FILE_NAME = "saved_pools_v1.enc" - } } /** Named in-memory vaults for concurrent repository tests. */ @@ -142,3 +308,6 @@ object SharedInMemoryVaults { map.getOrPut(name) { InMemoryVaultFileStore() } fun clearAll() = map.clear() } + +/** @deprecated Use [AndroidAtomicVaultFileStore] or [SimulatedAtomicVaultFileStore]. */ +typealias AtomicVaultFileStore = AndroidAtomicVaultFileStore diff --git a/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt b/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt new file mode 100644 index 0000000..1bbb894 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt @@ -0,0 +1,59 @@ +package com.pentaircontrol.pool.session + +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.ConnectionStatus + +/** + * Typed result of a single [SiteSession] connection attempt. + * + * Callers must bind post-connect actions (save, password update, last-success, + * active profile) to [Installed] only — never to global connection state after a + * Unit-returning connect that may have been superseded. + */ +sealed class ConnectionAttemptResult { + abstract val generation: Int + + /** + * This attempt owns the live session. + * [target] is the exact target that was installed (do not log secrets). + */ + class Installed( + override val generation: Int, + val target: AdapterConnectionTarget, + val controllerIdentity: String?, + val connectionStatus: ConnectionStatus + ) : ConnectionAttemptResult() { + override fun toString(): String = + "Installed(gen=$generation, identity=${controllerIdentity?.let { "***" } ?: "null"}, " + + "status=${connectionStatus::class.simpleName}, target=***)" + } + + class Failed( + override val generation: Int, + val message: String, + val authenticationRequired: Boolean = false + ) : ConnectionAttemptResult() { + override fun toString(): String = + "Failed(gen=$generation, authRequired=$authenticationRequired, message=$message)" + } + + /** Another connect/disconnect/close invalidated this attempt. */ + class Superseded(override val generation: Int) : ConnectionAttemptResult() { + override fun toString(): String = "Superseded(gen=$generation)" + } + + /** + * Candidate connected but failed validation (e.g. controller identity mismatch) + * and was never installed into the live session. + */ + class Rejected( + override val generation: Int, + val reason: String + ) : ConnectionAttemptResult() { + override fun toString(): String = "Rejected(gen=$generation, reason=$reason)" + } + + class Closed(override val generation: Int = -1) : ConnectionAttemptResult() { + override fun toString(): String = "Closed" + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt index d7094cc..d4a2136 100644 --- a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt +++ b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt @@ -212,11 +212,19 @@ class SiteSession( } } - suspend fun connectLocal(gateway: DiscoveredGateway, password: String = "") { - connect(ScreenLogicLocalTarget(gateway, password), screenLogicFactory) - } - - suspend fun connectManual(ip: String, port: Int, password: String = "") { + suspend fun connectLocal( + gateway: DiscoveredGateway, + password: String = "", + expectedControllerIdentity: String? = null + ): ConnectionAttemptResult = + connect(ScreenLogicLocalTarget(gateway, password), screenLogicFactory, expectedControllerIdentity) + + suspend fun connectManual( + ip: String, + port: Int, + password: String = "", + expectedControllerIdentity: String? = null + ): ConnectionAttemptResult { val gw = DiscoveredGateway( id = "$ip:$port", displayName = "Manual $ip", @@ -224,42 +232,71 @@ class SiteSession( port = port, path = ConnectionPath.LOCAL_LAN ) - connectLocal(gw, password) + return connectLocal(gw, password, expectedControllerIdentity) } - suspend fun connectRemote(systemCode: String, password: String = "") { + suspend fun connectRemote( + systemCode: String, + password: String = "", + expectedControllerIdentity: String? = null + ): ConnectionAttemptResult = connect( ScreenLogicRemoteTarget(systemCode.trim(), password), - screenLogicFactory + screenLogicFactory, + expectedControllerIdentity ) - } - suspend fun connectSimulator(profileName: String = "Demo EasyTouch") { + suspend fun connectSimulator(profileName: String = "Demo EasyTouch"): ConnectionAttemptResult = connect( SimulatorConnectionTarget(profileName), - { simulatorFactory() } + { simulatorFactory() }, + expectedControllerIdentity = null ) - } + /** + * Overload without expected identity — preserves existing trailing-lambda and + * two-argument call sites (`connectWithFactory(target) { ... }` / + * `connectWithFactory(target, factory)`). + */ suspend fun connectWithFactory( target: AdapterConnectionTarget, factory: () -> PoolSystemAdapter - ) { - connect(target, factory) - } + ): ConnectionAttemptResult = + connect(target, factory, expectedControllerIdentity = null) - suspend fun connectWithAdapter(target: AdapterConnectionTarget, newAdapter: PoolSystemAdapter) { - connect(target) { newAdapter } - } + /** + * Connect with optional pre-install controller-identity validation for saved pools. + */ + suspend fun connectWithFactory( + target: AdapterConnectionTarget, + factory: () -> PoolSystemAdapter, + expectedControllerIdentity: String? + ): ConnectionAttemptResult = + connect(target, factory, expectedControllerIdentity) + suspend fun connectWithAdapter( + target: AdapterConnectionTarget, + newAdapter: PoolSystemAdapter, + expectedControllerIdentity: String? = null + ): ConnectionAttemptResult = + connect(target, { newAdapter }, expectedControllerIdentity) + + /** + * Connect a candidate privately, validate optional expected controller identity, + * then install only if generation/target still own the session. + * + * Never publishes mismatched candidate site data or enables equipment controls + * for a rejected candidate. + */ private suspend fun connect( target: AdapterConnectionTarget, - factory: () -> PoolSystemAdapter - ) { + factory: () -> PoolSystemAdapter, + expectedControllerIdentity: String? + ): ConnectionAttemptResult { if (closed.get()) { _connection.value = ConnectionStatus.ConnectionFailed(CLOSED_MESSAGE) _lastError.value = CLOSED_MESSAGE - return + return ConnectionAttemptResult.Closed() } // New target selection: invalidate any in-flight reconnect/retry for prior generation. @@ -274,9 +311,7 @@ class SiteSession( _lastReject.value = null _canRetryConnection.value = false consecutiveRefreshFailures = 0 - // New target: clear in-memory fallback and identity. The controller's own - // identity (and any saved fallback for it) is only learned after the - // controller reports its ID — never derived from the connection target. + // Clear installed-session identity only — candidate identity is private until install. confirmedTemperatureUnit.set(null) activeControllerIdentity.set(null) _canRememberTemperatureUnit.value = false @@ -290,25 +325,70 @@ class SiteSession( try { instrumentation.onConnectCall() newAdapter.connect(target) + if (closed.get() || connectionGeneration.get() != gen || activeTargetRef.get() !== target) { + cleanup.scheduleDisconnect(newAdapter) + return if (closed.get()) ConnectionAttemptResult.Closed(gen) + else ConnectionAttemptResult.Superseded(gen) + } + + // Candidate snapshot — not yet published. var snap = newAdapter.snapshot.value ?: newAdapter.refresh() - snap = reconcileEffectiveTemperatureUnit(newAdapter, snap, gen) - if (!tryInstallAdapter(gen, target, newAdapter, snap)) { + val candidateIdentity = readCandidateIdentity(newAdapter) + + // Pre-install identity validation (local and remote when expected is known). + if (expectedControllerIdentity != null && + candidateIdentity != null && + expectedControllerIdentity != candidateIdentity + ) { cleanup.scheduleDisconnect(newAdapter) - if (closed.get()) { - _connection.value = ConnectionStatus.Disconnected - } else if (connectionGeneration.get() != gen) { - // Superseded by disconnect/close/another connect — leave their state. - } else { - _connection.value = ConnectionStatus.ConnectionFailed( - "Connection was canceled." - ) + if (closed.get() || connectionGeneration.get() != gen) { + return if (closed.get()) ConnectionAttemptResult.Closed(gen) + else ConnectionAttemptResult.Superseded(gen) + } + _connection.value = ConnectionStatus.ConnectionFailed( + "That device doesn't match the saved pool." + ) + _lastError.value = "Controller identity mismatch" + return ConnectionAttemptResult.Rejected( + generation = gen, + reason = "Controller identity mismatch" + ) + } + + // Prepare effective unit on the candidate without mutating global identity first. + snap = applyCandidateTemperatureUnit(newAdapter, snap, candidateIdentity) + + if (!tryInstallAdapter(gen, target, newAdapter, snap, candidateIdentity)) { + cleanup.scheduleDisconnect(newAdapter) + return when { + closed.get() -> { + _connection.value = ConnectionStatus.Disconnected + ConnectionAttemptResult.Closed(gen) + } + connectionGeneration.get() != gen || activeTargetRef.get() !== target -> + ConnectionAttemptResult.Superseded(gen) + else -> { + _connection.value = ConnectionStatus.ConnectionFailed( + "Connection was canceled." + ) + ConnectionAttemptResult.Failed(gen, "Connection was canceled.") + } } - return } + return ConnectionAttemptResult.Installed( + generation = gen, + target = target, + controllerIdentity = candidateIdentity, + connectionStatus = _connection.value + ) } catch (e: Exception) { cleanup.scheduleDisconnect(newAdapter) - if (closed.get() || connectionGeneration.get() != gen) return + if (closed.get()) return ConnectionAttemptResult.Closed(gen) + if (connectionGeneration.get() != gen || activeTargetRef.get() !== target) { + return ConnectionAttemptResult.Superseded(gen) + } val status = newAdapter.connection.value + val auth = status is ConnectionStatus.AuthenticationRequired _connection.value = when (status) { is ConnectionStatus.AuthenticationRequired -> status is ConnectionStatus.ConnectionFailed -> status @@ -317,6 +397,11 @@ class SiteSession( ) } _lastError.value = e.message + return ConnectionAttemptResult.Failed( + generation = gen, + message = e.message ?: "Connection failed", + authenticationRequired = auth + ) } } @@ -724,8 +809,10 @@ class SiteSession( instrumentation.onConnectCall() fresh.connect(target) var snap = fresh.snapshot.value ?: fresh.refresh() - snap = reconcileEffectiveTemperatureUnit(fresh, snap, generation) - if (!tryInstallAdapter(generation, target, fresh, snap)) { + val candidateIdentity = readCandidateIdentity(fresh) + ?: activeControllerIdentity.get() + snap = applyCandidateTemperatureUnit(fresh, snap, candidateIdentity) + if (!tryInstallAdapter(generation, target, fresh, snap, candidateIdentity)) { cleanup.scheduleDisconnect(fresh) return false } @@ -764,79 +851,69 @@ class SiteSession( generation: Int, target: AdapterConnectionTarget, fresh: PoolSystemAdapter, - snap: SiteSnapshot + snap: SiteSnapshot, + candidateIdentity: String? ): Boolean { if (!isGenerationLive(generation, target)) return false return installMutex.withLock { if (!isGenerationLive(generation, target)) return@withLock false + // Install only after validation — first publication of site/connection. adapterRef.set(fresh) _site.value = snap consecutiveRefreshFailures = 0 lastLivePath = target.path _connection.value = fresh.connection.value _canRetryConnection.value = false + // Global identity is applied only on successful install. + applyInstalledIdentity(candidateIdentity, fresh) instrumentation.onSuccessfulInstall() startPolling(forGeneration = generation) true } } + /** Read candidate identity without mutating global session state. */ + private fun readCandidateIdentity(adapter: PoolSystemAdapter): String? = + (adapter as? ControllerPreferenceIdentityProvider)?.controllerPreferenceIdentity() + /** - * Effective unit precedence: - * 1. Controller-reported / adapter-declared unit already on [snap] - * 2. Saved or session fallback ([confirmedTemperatureUnit]) - * 3. UNKNOWN (heat commands stay blocked) - * - * This runs on every connect/poll/refresh and therefore **never writes - * preferences**: controller-reported units are authoritative on their own and - * are not persisted; a saved fallback is only ever written by an explicit user - * confirmation. This is what keeps "Forget" forgotten across polling, - * refresh, reconnect, and process restarts. + * Apply temperature fallback on a candidate adapter using prefs for the candidate + * identity, without writing to [activeControllerIdentity] until install. */ - private fun reconcileEffectiveTemperatureUnit( + private fun applyCandidateTemperatureUnit( adapter: PoolSystemAdapter, snap: SiteSnapshot, - expectedGeneration: Int + candidateIdentity: String? ): SiteSnapshot { - updateControllerIdentity(adapter, expectedGeneration) when (snap.temperatureUnitSource) { TemperatureUnitSource.CONTROLLER, - TemperatureUnitSource.ADAPTER -> return snap // authoritative — never persisted + TemperatureUnitSource.ADAPTER -> return snap TemperatureUnitSource.SAVED_OR_SESSION, - TemperatureUnitSource.UNKNOWN -> Unit // may still need the fallback below + TemperatureUnitSource.UNKNOWN -> Unit } - if (snap.temperatureUnit != TemperatureUnit.UNKNOWN && snap.temperatureUnitSource == TemperatureUnitSource.SAVED_OR_SESSION ) { return snap } - - val fallback = confirmedTemperatureUnit.get() ?: return snap + val fallback = candidateIdentity?.let { ControllerTempUnitStore.load(settings, it) } + ?: confirmedTemperatureUnit.get() + ?: return snap if (fallback == TemperatureUnit.UNKNOWN) return snap applyFallbackToAdapter(adapter, fallback) return snap.withConfirmedUnit(fallback, TemperatureUnitSource.SAVED_OR_SESSION) } /** - * Learn the controller-reported preference identity once the adapter can - * provide it (post-CTRLCONFIG). Read-only against preferences; never writes. - * - * Guarded by [expectedGeneration]: a superseded connect attempt (user already - * started connecting elsewhere) resumes after its handshake and must not - * contaminate the newer session's identity, fallback, or UI flags. - * - * On the first identity of a session, restore that controller's saved fallback - * unless the user already confirmed a unit. If the identity *changes* - * mid-session (reconnect reached a different physical controller), the previous - * controller's fallback must not leak onto it — adopt the new controller's own - * saved value, or none. + * Apply controller identity for an installed session (never for a rejected candidate). + * Mid-session identity changes adopt the new controller's saved fallback and drop the old. */ - private fun updateControllerIdentity(adapter: PoolSystemAdapter, expectedGeneration: Int) { - if (closed.get() || connectionGeneration.get() != expectedGeneration) return - val identity = - (adapter as? ControllerPreferenceIdentityProvider)?.controllerPreferenceIdentity() - ?: return + private fun applyInstalledIdentity(identity: String?, adapter: PoolSystemAdapter) { + if (identity == null) { + _canRememberTemperatureUnit.value = false + _savedTemperatureUnit.value = null + return + } val previous = activeControllerIdentity.get() if (identity == previous) return activeControllerIdentity.set(identity) @@ -844,15 +921,56 @@ class SiteSession( val saved = ControllerTempUnitStore.load(settings, identity) _savedTemperatureUnit.value = saved if (previous != null) { - // Controller switch mid-session: drop the old controller's fallback. + // Controller switch mid-session: do not leak previous controller's fallback. confirmedTemperatureUnit.set(saved) applyFallbackToAdapter(adapter, saved ?: TemperatureUnit.UNKNOWN) } else if (saved != null && confirmedTemperatureUnit.get() == null) { - // First identity: restore unless the user already confirmed this session. confirmedTemperatureUnit.set(saved) + applyFallbackToAdapter(adapter, saved) } } + /** + * Effective unit precedence for installed session polls/refreshes: + * 1. Controller-reported / adapter-declared unit already on [snap] + * 2. Saved or session fallback ([confirmedTemperatureUnit]) + * 3. UNKNOWN (heat commands stay blocked) + * + * Never mutates global identity from a candidate that is not installed. + */ + private fun reconcileEffectiveTemperatureUnit( + adapter: PoolSystemAdapter, + snap: SiteSnapshot, + expectedGeneration: Int + ): SiteSnapshot { + // Mid-session identity updates only for the installed adapter/generation. + if (!closed.get() && connectionGeneration.get() == expectedGeneration && + adapterRef.get() === adapter + ) { + val identity = readCandidateIdentity(adapter) + if (identity != null && identity != activeControllerIdentity.get()) { + applyInstalledIdentity(identity, adapter) + } + } + when (snap.temperatureUnitSource) { + TemperatureUnitSource.CONTROLLER, + TemperatureUnitSource.ADAPTER -> return snap + TemperatureUnitSource.SAVED_OR_SESSION, + TemperatureUnitSource.UNKNOWN -> Unit + } + + if (snap.temperatureUnit != TemperatureUnit.UNKNOWN && + snap.temperatureUnitSource == TemperatureUnitSource.SAVED_OR_SESSION + ) { + return snap + } + + val fallback = confirmedTemperatureUnit.get() ?: return snap + if (fallback == TemperatureUnit.UNKNOWN) return snap + applyFallbackToAdapter(adapter, fallback) + return snap.withConfirmedUnit(fallback, TemperatureUnitSource.SAVED_OR_SESSION) + } + private fun applyFallbackToAdapter(adapter: PoolSystemAdapter?, unit: TemperatureUnit?) { if (adapter == null || unit == null) return if (adapter is ScreenLogicAdapter) { diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt index bd55e87..4be5367 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt @@ -56,6 +56,7 @@ import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.savedpools.ConnectSaveOptions +import com.pentaircontrol.pool.savedpools.LocalRecoveryState import com.pentaircontrol.pool.savedpools.SavedPoolId import com.pentaircontrol.pool.savedpools.SavedPoolPasswordPrompt import com.pentaircontrol.pool.savedpools.SavedPoolsListState @@ -122,15 +123,22 @@ fun PoolApp( savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), passwordPrompt: SavedPoolPasswordPrompt? = null, onConnectSavedPool: (SavedPoolId) -> Unit = {}, - onSubmitSavedPassword: (SavedPoolId, String, Boolean) -> Unit = { _, _, _ -> }, + onSubmitSavedPassword: (SavedPoolId, String, Boolean, Int) -> Unit = { _, _, _, _ -> }, onDismissPasswordPrompt: () -> Unit = {}, onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, onDeleteSavedPool: (SavedPoolId) -> Unit = {}, onSaveCurrentPool: () -> Unit = {}, onMarkPreferredSavedPool: (SavedPoolId) -> Unit = {}, + onForgetRememberedPassword: (SavedPoolId, com.pentaircontrol.pool.savedpools.SavedRouteKind) -> Unit = + { _, _ -> }, onRetryVault: () -> Unit = {}, onResetVault: () -> Unit = {}, - vaultDiagnostics: String? = null + vaultDiagnostics: String? = null, + localRecovery: LocalRecoveryState = LocalRecoveryState.Idle, + onStartLocalRecovery: (SavedPoolId) -> Unit = {}, + onConnectRecoveredGateway: (SavedPoolId, DiscoveredGateway, String, Boolean) -> Unit = + { _, _, _, _ -> }, + onDismissLocalRecovery: () -> Unit = {} ) { val showMainShell = connection.isLiveConnected || connection is ConnectionStatus.StaleOrDegraded || @@ -166,7 +174,11 @@ fun PoolApp( onRenameSavedPool = onRenameSavedPool, onDeleteSavedPool = onDeleteSavedPool, onRetryVault = onRetryVault, - onResetVault = onResetVault + onResetVault = onResetVault, + localRecovery = localRecovery, + onStartLocalRecovery = onStartLocalRecovery, + onConnectRecoveredGateway = onConnectRecoveredGateway, + onDismissLocalRecovery = onDismissLocalRecovery ) return } @@ -341,6 +353,7 @@ fun PoolApp( onDeleteSavedPool = onDeleteSavedPool, onSaveCurrentPool = onSaveCurrentPool, onMarkPreferred = onMarkPreferredSavedPool, + onForgetRememberedPassword = onForgetRememberedPassword, onResetVault = onResetVault, vaultDiagnostics = vaultDiagnostics ) diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt index 326c2b8..a6b2727 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt @@ -19,17 +19,21 @@ import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.savedpools.ConnectSaveOptions +import com.pentaircontrol.pool.savedpools.LocalRecoveryState +import com.pentaircontrol.pool.savedpools.PasswordSaveDecision +import com.pentaircontrol.pool.savedpools.SaveProfileResult import com.pentaircontrol.pool.savedpools.SavedPoolConnectionResult import com.pentaircontrol.pool.savedpools.SavedPoolDraftFactory import com.pentaircontrol.pool.savedpools.SavedPoolId import com.pentaircontrol.pool.savedpools.SavedPoolPasswordPrompt import com.pentaircontrol.pool.savedpools.SavedPoolRepository import com.pentaircontrol.pool.savedpools.SavedPoolRepositoryFactory -import com.pentaircontrol.pool.savedpools.SavedPoolSummary import com.pentaircontrol.pool.savedpools.SavedPoolsListState import com.pentaircontrol.pool.savedpools.SavedRouteKind import com.pentaircontrol.pool.savedpools.VaultException import com.pentaircontrol.pool.savedpools.VaultHealth +import com.pentaircontrol.pool.savedpools.VaultResetResult +import com.pentaircontrol.pool.session.ConnectionAttemptResult import com.pentaircontrol.pool.session.SiteSession import com.pentaircontrol.pool.settings.AndroidAppPreferencesStore import com.pentaircontrol.pool.settings.AppPreferencesStore @@ -40,6 +44,7 @@ import com.pentaircontrol.pool.ui.presentation.receiptSummary import com.pentaircontrol.pool.ui.theme.AppearanceMode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -47,15 +52,18 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger /** - * UI ViewModel — domain types only. No protocol models or packet codes. + * UI ViewModel — domain types only. * - * Saved-pool secrets are never held in [StateFlow] or Compose state. - * Decrypted connection material exists only long enough to begin [SiteSession] connect. + * ## Connection request epoch + * Every user-initiated connect (including saved-pool decrypt) captures a request epoch. + * Starting request B invalidates A before A may decrypt, connect, install, save, or + * show a password prompt. Disconnect / [onCleared] invalidates all outstanding work. * - * [sessionFactory] receives [viewModelScope] so all session work is canceled when the - * ViewModel is cleared. [onCleared] also calls [SiteSession.close] for adapter teardown. + * Secrets never live in [StateFlow]. Post-connect vault writes bind only to + * [ConnectionAttemptResult.Installed] for that exact attempt. */ class PoolViewModel( private val preferences: AppPreferencesStore = InMemoryAppPreferencesStore(), @@ -65,7 +73,9 @@ class PoolViewModel( private val session: SiteSession = sessionFactory(viewModelScope) - /** Non-sensitive display preference: System / Light / Dark appearance. */ + private val connectionRequestEpoch = AtomicInteger(0) + private var activeConnectionJob: Job? = null + private val _appearance = MutableStateFlow( AppearanceMode.fromStorage(preferences.getString(AppPreferencesStore.KEY_APPEARANCE)) ) @@ -107,8 +117,6 @@ class PoolViewModel( private val _statusMessage = MutableStateFlow(null) val statusMessage: StateFlow = _statusMessage - // ——— Saved pools (non-secret UI state only) ——— - private val _savedPoolsState = MutableStateFlow(SavedPoolsListState.Loading) val savedPoolsState: StateFlow = _savedPoolsState.asStateFlow() @@ -116,23 +124,30 @@ class PoolViewModel( private val _passwordPrompt = MutableStateFlow(null) val passwordPrompt: StateFlow = _passwordPrompt.asStateFlow() - /** Profile id of the currently connected saved pool, if any (non-secret). */ private val _activeSavedProfileId = MutableStateFlow(null) val activeSavedProfileId: StateFlow = _activeSavedProfileId.asStateFlow() - /** Pending password-remember consent for a password-prompt reconnect (not a secret). */ - private var pendingRememberPassword: Boolean = false - private var pendingPasswordProfileId: SavedPoolId? = null - private var pendingPasswordRouteKind: SavedRouteKind? = null + private val _localRecovery = MutableStateFlow(LocalRecoveryState.Idle) + val localRecovery: StateFlow = _localRecovery.asStateFlow() init { refreshSavedPools() } - /** Test hook: inspect the bound session. */ internal fun sessionForTest(): SiteSession = session - internal fun savedPoolsForTest(): SavedPoolRepository? = savedPools + internal fun connectionRequestEpochForTest(): Int = connectionRequestEpoch.get() + + /** Begin a new user connection request; invalidates all prior request epochs. */ + private fun beginConnectionRequest(): Int { + activeConnectionJob?.cancel() + val epoch = connectionRequestEpoch.incrementAndGet() + _passwordPrompt.value = null + return epoch + } + + private fun isRequestLive(epoch: Int): Boolean = + connectionRequestEpoch.get() == epoch && !session.isClosed() fun refreshSavedPools() { val repo = savedPools @@ -166,9 +181,28 @@ class PoolViewModel( password: String = "", save: ConnectSaveOptions = ConnectSaveOptions() ) { - viewModelScope.launch { - session.connectLocal(gateway, password) - maybeSaveLocal(gateway, password, save) + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + val result = session.connectLocal(gateway, password) + if (!isRequestLive(epoch)) return@launch + handleConnectResult( + epoch = epoch, + result = result, + save = save, + profileId = null, + routeKind = SavedRouteKind.SCREENLOGIC_LOCAL, + draftBuilder = { identity -> + SavedPoolDraftFactory.fromLocal( + gateway = gateway, + password = password, + controllerIdentity = identity, + passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + save.rememberPassword, password.isEmpty() + ) + ) + } + ) } } @@ -192,118 +226,129 @@ class PoolViewModel( password: String, save: ConnectSaveOptions = ConnectSaveOptions() ) { - viewModelScope.launch { - session.connectRemote(systemCode, password) - if (!session.connection.value.isLiveConnected) return@launch - if (!save.savePool || savedPools == null) return@launch - try { - val identity = session.activeControllerIdentity() - val draft = SavedPoolDraftFactory.fromRemote( - systemCode = systemCode, - password = password, - displayName = "Remote pool", - controllerIdentity = identity, - savePassword = save.rememberPassword - ) - withContext(Dispatchers.IO) { - savedPools.saveAfterSuccessfulConnection(draft) + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + val result = session.connectRemote(systemCode, password) + if (!isRequestLive(epoch)) return@launch + handleConnectResult( + epoch = epoch, + result = result, + save = save, + profileId = null, + routeKind = SavedRouteKind.SCREENLOGIC_REMOTE, + draftBuilder = { identity -> + SavedPoolDraftFactory.fromRemote( + systemCode = systemCode, + password = password, + displayName = "Remote pool", + controllerIdentity = identity, + passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + save.rememberPassword, password.isEmpty() + ) + ) } - refreshSavedPools() - } catch (_: Exception) { - _statusMessage.value = - "Connected, but the pool could not be saved securely on this phone." - } + ) } } fun connectSimulator() { - viewModelScope.launch { session.connectSimulator() } + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + session.connectSimulator() + } } - /** - * One-tap reconnect from a saved profile summary. - * Decrypts on IO; never puts secrets into StateFlow. - * If password is required and not stored, shows [passwordPrompt] only. - */ fun connectSavedPool(profileId: SavedPoolId) { val repo = savedPools ?: return - viewModelScope.launch { - _passwordPrompt.value = null - val result = try { - withContext(Dispatchers.IO) { - repo.loadConnection(profileId) - } + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + val load = try { + withContext(Dispatchers.IO) { repo.loadConnection(profileId) } } catch (e: VaultException) { + if (!isRequestLive(epoch)) return@launch _statusMessage.value = vaultFailureMessage(e.health) return@launch } - when (result) { + if (!isRequestLive(epoch)) return@launch + when (load) { is SavedPoolConnectionResult.PasswordRequired -> { + if (!isRequestLive(epoch)) return@launch _passwordPrompt.value = SavedPoolPasswordPrompt( - profileId = result.profileId, - displayName = result.displayName, - routeKind = result.routeKind + profileId = load.profileId, + displayName = load.displayName, + routeKind = load.routeKind, + replacingFailedStoredPassword = load.replacingFailedStoredPassword, + requestEpoch = epoch ) } is SavedPoolConnectionResult.Ready -> { - completeSavedConnect(repo, result, passwordOverride = null, rememberPassword = false) + completeSavedConnect(epoch, repo, load, passwordOverride = null, rememberPassword = false) } is SavedPoolConnectionResult.VaultUnavailable -> { - _savedPoolsState.value = SavedPoolsListState.Unreadable(result.health) - _statusMessage.value = vaultFailureMessage(result.health) + _savedPoolsState.value = SavedPoolsListState.Unreadable(load.health) + _statusMessage.value = vaultFailureMessage(load.health) } is SavedPoolConnectionResult.NotFound -> { _statusMessage.value = "That saved pool is no longer available." refreshSavedPools() } is SavedPoolConnectionResult.Failure -> { - _statusMessage.value = result.message + // Saved local address may have failed — offer recovery if local. + maybeStartLocalRecovery(profileId, load.message) } is SavedPoolConnectionResult.IdentityMismatch -> { - _statusMessage.value = result.message + _statusMessage.value = load.message } } } } - /** - * Complete a password-required reconnect. Password is not retained in UI state. - */ fun submitSavedPoolPassword( profileId: SavedPoolId, password: String, - rememberPassword: Boolean = false + rememberPassword: Boolean = false, + promptEpoch: Int? = null ) { val repo = savedPools ?: return - viewModelScope.launch { + // Stale password prompt for an older request must not connect. + if (promptEpoch != null && promptEpoch != connectionRequestEpoch.get()) { + _passwordPrompt.value = null + return + } + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch _passwordPrompt.value = null - pendingRememberPassword = rememberPassword - pendingPasswordProfileId = profileId - val result = try { + val load = try { withContext(Dispatchers.IO) { repo.loadConnection(profileId, passwordOverride = password) } } catch (e: VaultException) { + if (!isRequestLive(epoch)) return@launch _statusMessage.value = vaultFailureMessage(e.health) return@launch } - when (result) { + if (!isRequestLive(epoch)) return@launch + when (load) { is SavedPoolConnectionResult.Ready -> { - pendingPasswordRouteKind = null - completeSavedConnect(repo, result, password, rememberPassword) + completeSavedConnect(epoch, repo, load, password, rememberPassword) } is SavedPoolConnectionResult.PasswordRequired -> { + if (!isRequestLive(epoch)) return@launch _passwordPrompt.value = SavedPoolPasswordPrompt( - profileId = result.profileId, - displayName = result.displayName, - routeKind = result.routeKind + profileId = load.profileId, + displayName = load.displayName, + routeKind = load.routeKind, + requestEpoch = epoch ) } - is SavedPoolConnectionResult.VaultUnavailable -> { - _statusMessage.value = vaultFailureMessage(result.health) - } else -> { - _statusMessage.value = "Couldn't prepare that saved pool." + if (isRequestLive(epoch)) { + _statusMessage.value = "Couldn't prepare that saved pool." + } } } } @@ -311,9 +356,6 @@ class PoolViewModel( fun dismissPasswordPrompt() { _passwordPrompt.value = null - pendingRememberPassword = false - pendingPasswordProfileId = null - pendingPasswordRouteKind = null } fun renameSavedPool(profileId: SavedPoolId, displayName: String) { @@ -341,6 +383,11 @@ class PoolViewModel( } else { _statusMessage.value = "Saved pool removed" } + if (_localRecovery.value is LocalRecoveryState.NeedsDiscovery && + (_localRecovery.value as LocalRecoveryState.NeedsDiscovery).profileId == profileId + ) { + _localRecovery.value = LocalRecoveryState.Idle + } refreshSavedPools() } catch (_: Exception) { _statusMessage.value = "Couldn't remove that saved pool." @@ -352,17 +399,31 @@ class PoolViewModel( val repo = savedPools ?: return viewModelScope.launch { try { - withContext(Dispatchers.IO) { repo.markPreferredRecent(profileId) } + withContext(Dispatchers.IO) { repo.setPreferred(profileId, preferred = true) } + refreshSavedPools() + } catch (_: Exception) { + } + } + } + + fun forgetRememberedPassword(profileId: SavedPoolId, kind: SavedRouteKind) { + val repo = savedPools ?: return + viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + repo.applyPasswordDecision( + profileId, kind, password = null, + decision = PasswordSaveDecision.REMOVE_EXISTING + ) + } refreshSavedPools() + _statusMessage.value = "Remembered password removed for this pool" } catch (_: Exception) { + _statusMessage.value = "Couldn't update password settings." } } } - /** - * Save the currently connected pool if it was not saved at connect time. - * Uses the in-memory session target only (never ordinary preferences). - */ fun saveCurrentConnectedPool(rememberPassword: Boolean = false) { val repo = savedPools ?: return viewModelScope.launch { @@ -379,22 +440,39 @@ class PoolViewModel( password = target.password, displayName = "Remote pool", controllerIdentity = identity, - savePassword = rememberPassword + passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + rememberPassword, target.password.isEmpty() + ) ) is ScreenLogicLocalTarget -> SavedPoolDraftFactory.fromLocal( gateway = target.gateway, password = target.password, controllerIdentity = identity, - savePassword = rememberPassword + passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + rememberPassword, target.password.isEmpty() + ) ) else -> { _statusMessage.value = "This connection type can't be saved yet." return@launch } } - withContext(Dispatchers.IO) { repo.saveAfterSuccessfulConnection(draft) } - refreshSavedPools() - _statusMessage.value = "Pool saved securely on this phone" + when (val r = withContext(Dispatchers.IO) { + repo.saveAfterSuccessfulConnection(draft) + }) { + is SaveProfileResult.Saved -> { + refreshSavedPools() + _statusMessage.value = "Pool saved securely on this phone" + } + is SaveProfileResult.ProfileLimitReached -> { + _statusMessage.value = + "You've reached the saved-pool limit (${r.maxProfiles}). " + + "Remove an old pool in Settings, then try again." + } + is SaveProfileResult.VaultError -> { + _statusMessage.value = "Couldn't save this pool securely." + } + } } catch (_: Exception) { _statusMessage.value = "Couldn't save this pool securely." } @@ -404,23 +482,156 @@ class PoolViewModel( fun resetSavedPoolsVault() { val repo = savedPools ?: return viewModelScope.launch { + when (val r = withContext(Dispatchers.IO) { repo.resetVault() }) { + VaultResetResult.Success -> { + _activeSavedProfileId.value = null + _passwordPrompt.value = null + _localRecovery.value = LocalRecoveryState.Idle + _savedPoolsState.value = SavedPoolsListState.Available(emptyList()) + _statusMessage.value = "Saved pools were reset on this phone" + } + is VaultResetResult.PartialFailure -> { + _statusMessage.value = + "Saved pools reset was incomplete. You can try again." + refreshSavedPools() + } + } + } + } + + // ——— Local DHCP recovery ——— + + fun startLocalRecoveryDiscovery(profileId: SavedPoolId) { + val epoch = connectionRequestEpoch.get() + viewModelScope.launch { + _localRecovery.value = LocalRecoveryState.Discovering(profileId) try { - withContext(Dispatchers.IO) { repo.resetVault() } - _activeSavedProfileId.value = null - _passwordPrompt.value = null - _savedPoolsState.value = SavedPoolsListState.Available(emptyList()) - _statusMessage.value = "Saved pools were reset on this phone" + session.discoverLocal() } catch (_: Exception) { - _statusMessage.value = "Couldn't reset saved pools." + } + if (connectionRequestEpoch.get() != epoch && + _localRecovery.value is LocalRecoveryState.Discovering + ) { + // A newer connect superseded — leave recovery idle if still discovering. + } + val found = session.gateways.value + _localRecovery.value = if (found.isEmpty()) { + LocalRecoveryState.NeedsDiscovery( + profileId = profileId, + message = "No controllers found on this Wi‑Fi." + ) + } else { + LocalRecoveryState.GatewaysFound(profileId, found) } } } + fun connectRecoveredGateway( + profileId: SavedPoolId, + gateway: DiscoveredGateway, + password: String = "", + rememberPassword: Boolean = false + ) { + val repo = savedPools ?: return + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + val expected = withContext(Dispatchers.IO) { + repo.expectedControllerIdentity(profileId) + } + if (!isRequestLive(epoch)) return@launch + // When expected identity is unavailable, do not silently replace routes. + if (expected == null) { + _statusMessage.value = + "This saved pool has no controller ID yet. " + + "Connect only if you're sure this is the same pool, " + + "or remove and re-save it." + // Still allow explicit user selection but require identity when available after connect + } + val result = session.connectLocal( + gateway = gateway, + password = password, + expectedControllerIdentity = expected + ) + if (!isRequestLive(epoch)) return@launch + when (result) { + is ConnectionAttemptResult.Installed -> { + // Update address only after identity validation (enforced in SiteSession). + if (expected != null && result.controllerIdentity != null && + expected != result.controllerIdentity + ) { + // Should not install — defensive + return@launch + } + if (expected == null && result.controllerIdentity == null) { + // Conservative: do not update encrypted address without identity evidence + _statusMessage.value = + "Connected, but the saved address was not updated " + + "(controller ID unavailable)." + } else { + withContext(Dispatchers.IO) { + repo.updateLocalAddress( + profileId = profileId, + address = gateway.address, + port = gateway.port, + gatewayLabel = gateway.displayName, + controllerIdentity = result.controllerIdentity ?: expected + ) + repo.recordSuccessfulConnection( + profileId, System.currentTimeMillis() + ) + if (rememberPassword && password.isNotEmpty()) { + repo.applyPasswordDecision( + profileId, + SavedRouteKind.SCREENLOGIC_LOCAL, + password, + PasswordSaveDecision.STORE_NEW + ) + } + } + _statusMessage.value = "Local address updated for this saved pool" + } + _activeSavedProfileId.value = profileId + _localRecovery.value = LocalRecoveryState.Idle + refreshSavedPools() + } + is ConnectionAttemptResult.Rejected -> { + _statusMessage.value = + "That device doesn't match the saved pool. Address not updated." + _localRecovery.value = LocalRecoveryState.NeedsDiscovery( + profileId, "Identity mismatch — try another device." + ) + } + is ConnectionAttemptResult.Failed -> { + if (result.authenticationRequired) { + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = profileId, + displayName = gateway.displayName, + routeKind = SavedRouteKind.SCREENLOGIC_LOCAL, + replacingFailedStoredPassword = true, + requestEpoch = epoch + ) + } else { + _statusMessage.value = "Couldn't connect to the selected device." + } + } + is ConnectionAttemptResult.Superseded, + is ConnectionAttemptResult.Closed -> Unit + } + } + } + + fun dismissLocalRecovery() { + _localRecovery.value = LocalRecoveryState.Idle + } + fun disconnect() { + beginConnectionRequest() // invalidate outstanding vault/connect work viewModelScope.launch { session.disconnect() _activeSavedProfileId.value = null - dismissPasswordPrompt() + _passwordPrompt.value = null + _localRecovery.value = LocalRecoveryState.Idle } } @@ -457,17 +668,14 @@ class PoolViewModel( fun toggleCircuit(circuitId: CircuitId, currentlyOn: Boolean) { viewModelScope.launch { - val result = session.toggleCircuit(circuitId, currentlyOn) - publishResult(result) + publishResult(session.toggleCircuit(circuitId, currentlyOn)) } } fun setHeatSetPoint(bodyId: BodyId, temp: Temperature) { viewModelScope.launch { val result = session.setHeatSetPoint(bodyId, temp, elevatedConfirmed = false) - if (!result.requiresConfirmation) { - publishResult(result) - } + if (!result.requiresConfirmation) publishResult(result) } } @@ -475,12 +683,13 @@ class PoolViewModel( val pending = session.pendingConfirmation.value ?: return viewModelScope.launch { session.clearPendingConfirmation() - val result = session.setHeatSetPoint( - bodyId = pending.bodyId, - temperature = pending.temperature, - elevatedConfirmed = true + publishResult( + session.setHeatSetPoint( + bodyId = pending.bodyId, + temperature = pending.temperature, + elevatedConfirmed = true + ) ) - publishResult(result) } } @@ -489,109 +698,155 @@ class PoolViewModel( } fun setHeatMode(bodyId: BodyId, mode: HeatMode) { - viewModelScope.launch { - publishResult(session.setHeatMode(bodyId, mode)) - } + viewModelScope.launch { publishResult(session.setHeatMode(bodyId, mode)) } } fun sendLightProgram(programId: LightProgramId, targetId: LightTargetId? = null) { - viewModelScope.launch { - publishResult(session.sendLightProgram(programId, targetId)) - } - } - - private suspend fun maybeSaveLocal( - gateway: DiscoveredGateway, - password: String, - save: ConnectSaveOptions - ) { - if (!session.connection.value.isLiveConnected) return - if (!save.savePool || savedPools == null) return - try { - val identity = session.activeControllerIdentity() - val draft = SavedPoolDraftFactory.fromLocal( - gateway = gateway, - password = password, - controllerIdentity = identity, - savePassword = save.rememberPassword - ) - withContext(Dispatchers.IO) { - savedPools.saveAfterSuccessfulConnection(draft) - } - refreshSavedPools() - } catch (_: Exception) { - _statusMessage.value = - "Connected, but the pool could not be saved securely on this phone." - } + viewModelScope.launch { publishResult(session.sendLightProgram(programId, targetId)) } } private suspend fun completeSavedConnect( + epoch: Int, repo: SavedPoolRepository, ready: SavedPoolConnectionResult.Ready, passwordOverride: String?, rememberPassword: Boolean ) { - val expectedIdentity = withContext(Dispatchers.IO) { - repo.expectedControllerIdentity(ready.profileId) - } - // Use the same ScreenLogic factory path as ordinary connects. - session.connectWithFactory(ready.connectionTarget) { ScreenLogicAdapter() } - - if (!session.connection.value.isLiveConnected) { - // Failed reconnect: keep profile, keep last-success timestamp. - _statusMessage.value = - "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." - return - } - - // Identity mismatch: reject install by disconnecting the candidate. - val actualIdentity = session.activeControllerIdentity() - if (expectedIdentity != null && actualIdentity != null && - expectedIdentity != actualIdentity - ) { - session.disconnect() - _statusMessage.value = - "That device doesn't match the saved pool. Connection was not kept." - return - } - - _activeSavedProfileId.value = ready.profileId - try { - withContext(Dispatchers.IO) { - repo.recordSuccessfulConnection(ready.profileId, System.currentTimeMillis()) - // Update local address if we connected via a local target (DHCP recovery path). - val target = ready.connectionTarget - if (target is ScreenLogicLocalTarget) { - repo.updateLocalAddress( + if (!isRequestLive(epoch)) return + val result = session.connectWithFactory( + ready.connectionTarget, + { ScreenLogicAdapter() }, + ready.expectedControllerIdentity + ) + if (!isRequestLive(epoch)) return + when (result) { + is ConnectionAttemptResult.Installed -> { + // Bind success only to this attempt's generation/target ownership. + _activeSavedProfileId.value = ready.profileId + try { + withContext(Dispatchers.IO) { + repo.recordSuccessfulConnection( + ready.profileId, System.currentTimeMillis() + ) + val target = ready.connectionTarget + if (target is ScreenLogicLocalTarget) { + repo.updateLocalAddress( + profileId = ready.profileId, + address = target.gateway.address, + port = target.gateway.port, + gatewayLabel = target.gateway.displayName, + controllerIdentity = result.controllerIdentity + ?: ready.expectedControllerIdentity + ) + } + if (passwordOverride != null) { + val decision = if (rememberPassword && passwordOverride.isNotEmpty()) { + PasswordSaveDecision.STORE_NEW + } else if (ready.usedStoredPassword && !rememberPassword) { + // Successful replacement with remember off after bad stored password + PasswordSaveDecision.REMOVE_EXISTING + } else if (rememberPassword) { + PasswordSaveDecision.STORE_NEW + } else { + PasswordSaveDecision.PRESERVE_EXISTING + } + if (decision != PasswordSaveDecision.PRESERVE_EXISTING) { + repo.applyPasswordDecision( + ready.profileId, + ready.routeKind, + passwordOverride, + decision + ) + } + } + } + refreshSavedPools() + } catch (_: Exception) { + } + _localRecovery.value = LocalRecoveryState.Idle + } + is ConnectionAttemptResult.Rejected -> { + _statusMessage.value = + "That device doesn't match the saved pool. Connection was not kept." + } + is ConnectionAttemptResult.Failed -> { + if (result.authenticationRequired && ready.usedStoredPassword) { + // Keep profile and stored password; prompt for replacement only. + _passwordPrompt.value = SavedPoolPasswordPrompt( profileId = ready.profileId, - address = target.gateway.address, - port = target.gateway.port, - gatewayLabel = target.gateway.displayName, - controllerIdentity = actualIdentity ?: expectedIdentity + displayName = ready.displayName, + routeKind = ready.routeKind, + replacingFailedStoredPassword = true, + requestEpoch = epoch ) + _statusMessage.value = "The saved password wasn't accepted." + } else { + maybeStartLocalRecovery(ready.profileId, result.message) + _statusMessage.value = + "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." } - if (rememberPassword && passwordOverride != null) { - val kind = when (target) { - is ScreenLogicRemoteTarget -> SavedRouteKind.SCREENLOGIC_REMOTE - is ScreenLogicLocalTarget -> SavedRouteKind.SCREENLOGIC_LOCAL - else -> null - } - if (kind != null) { - repo.updatePasswordAfterSuccess( - profileId = ready.profileId, - kind = kind, - password = passwordOverride, - rememberPassword = true - ) + } + is ConnectionAttemptResult.Superseded, + is ConnectionAttemptResult.Closed -> { + // Must not record success or disconnect another profile's session. + } + } + } + + private suspend fun handleConnectResult( + epoch: Int, + result: ConnectionAttemptResult, + save: ConnectSaveOptions, + profileId: SavedPoolId?, + routeKind: SavedRouteKind, + draftBuilder: (String?) -> com.pentaircontrol.pool.savedpools.SavedPoolDraft + ) { + if (!isRequestLive(epoch)) return + when (result) { + is ConnectionAttemptResult.Installed -> { + if (profileId != null) { + _activeSavedProfileId.value = profileId + } + if (save.savePool && savedPools != null) { + try { + val draft = draftBuilder(result.controllerIdentity) + when (val r = withContext(Dispatchers.IO) { + savedPools.saveAfterSuccessfulConnection(draft) + }) { + is SaveProfileResult.Saved -> refreshSavedPools() + is SaveProfileResult.ProfileLimitReached -> { + _statusMessage.value = + "Connected, but saved-pool limit reached (${r.maxProfiles}). " + + "Remove an old pool to save this one." + } + is SaveProfileResult.VaultError -> { + _statusMessage.value = + "Connected, but the pool could not be saved securely on this phone." + } + } + } catch (_: Exception) { + if (isRequestLive(epoch)) { + _statusMessage.value = + "Connected, but the pool could not be saved securely on this phone." + } } } } - refreshSavedPools() - } catch (_: Exception) { - // Connection succeeded; vault update is best-effort. + is ConnectionAttemptResult.Failed, + is ConnectionAttemptResult.Rejected, + is ConnectionAttemptResult.Superseded, + is ConnectionAttemptResult.Closed -> { + // Must not save credentials for non-installed attempts. + } } - pendingRememberPassword = false - pendingPasswordProfileId = null + } + + private fun maybeStartLocalRecovery(profileId: SavedPoolId, message: String?) { + // Offer Find on Wi-Fi after local route failure — non-secret UI state only. + _localRecovery.value = LocalRecoveryState.NeedsDiscovery( + profileId = profileId, + message = message ?: "Couldn't reach the saved local address." + ) } private fun vaultFailureMessage(health: VaultHealth): String = when (health) { @@ -619,10 +874,6 @@ class PoolViewModel( private fun formatReceipt(receipt: CommandReceipt): String = receiptSummary(receipt, site.value) - /** - * Clear the transient status message. When [expected] is given, only clears if the - * current message still matches — so an auto-dismiss timer can't wipe a newer message. - */ fun clearStatusMessage(expected: String? = null) { if (expected == null) { _statusMessage.value = null @@ -632,6 +883,7 @@ class PoolViewModel( } override fun onCleared() { + beginConnectionRequest() session.close() super.onCleared() } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt index 76b2ebf..63e8385 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt @@ -98,12 +98,18 @@ fun ConnectScreen( savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), passwordPrompt: SavedPoolPasswordPrompt? = null, onConnectSavedPool: (SavedPoolId) -> Unit = {}, - onSubmitSavedPassword: (SavedPoolId, String, Boolean) -> Unit = { _, _, _ -> }, + onSubmitSavedPassword: (SavedPoolId, String, Boolean, Int) -> Unit = { _, _, _, _ -> }, onDismissPasswordPrompt: () -> Unit = {}, onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, onDeleteSavedPool: (SavedPoolId) -> Unit = {}, onRetryVault: () -> Unit = {}, - onResetVault: () -> Unit = {} + onResetVault: () -> Unit = {}, + localRecovery: com.pentaircontrol.pool.savedpools.LocalRecoveryState = + com.pentaircontrol.pool.savedpools.LocalRecoveryState.Idle, + onStartLocalRecovery: (SavedPoolId) -> Unit = {}, + onConnectRecoveredGateway: (SavedPoolId, DiscoveredGateway, String, Boolean) -> Unit = + { _, _, _, _ -> }, + onDismissLocalRecovery: () -> Unit = {} ) { var expanded by rememberSaveable { mutableStateOf(null) } var showAppearanceDialog by remember { mutableStateOf(false) } @@ -206,6 +212,14 @@ fun ConnectScreen( } } + LocalRecoverySection( + state = localRecovery, + busy = busy, + onFind = onStartLocalRecovery, + onConnectGateway = onConnectRecoveredGateway, + onDismiss = onDismissLocalRecovery + ) + ConnectOptionCard( icon = Icons.Outlined.Search, title = "Find my pool", @@ -306,8 +320,11 @@ fun ConnectScreen( passwordPrompt?.let { prompt -> SavedPasswordDialog( displayName = prompt.displayName, + replacing = prompt.replacingFailedStoredPassword, onSubmit = { password, remember -> - onSubmitSavedPassword(prompt.profileId, password, remember) + onSubmitSavedPassword( + prompt.profileId, password, remember, prompt.requestEpoch + ) }, onDismiss = onDismissPasswordPrompt ) @@ -509,9 +526,97 @@ private fun VaultUnreadableCard(onRetry: () -> Unit, onReset: () -> Unit) { } } +@Composable +private fun LocalRecoverySection( + state: com.pentaircontrol.pool.savedpools.LocalRecoveryState, + busy: Boolean, + onFind: (SavedPoolId) -> Unit, + onConnectGateway: (SavedPoolId, DiscoveredGateway, String, Boolean) -> Unit, + onDismiss: () -> Unit +) { + when (state) { + is com.pentaircontrol.pool.savedpools.LocalRecoveryState.Idle -> Unit + is com.pentaircontrol.pool.savedpools.LocalRecoveryState.NeedsDiscovery -> { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(14.dp) + ) { + Column(Modifier.padding(14.dp)) { + Text( + "Couldn't reach the saved local address", + fontWeight = FontWeight.SemiBold + ) + Text( + state.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Find on Wi‑Fi searches this network only when you ask. " + + "WestCurrent will not log into every device automatically.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp) + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + Button( + onClick = { onFind(state.profileId) }, + enabled = !busy, + modifier = Modifier.defaultMinSize(minHeight = 48.dp) + ) { Text("Find on Wi‑Fi") } + TextButton(onClick = onDismiss) { Text("Dismiss") } + } + } + } + } + is com.pentaircontrol.pool.savedpools.LocalRecoveryState.Discovering -> { + Text( + "Searching this Wi‑Fi…", + modifier = Modifier.padding(bottom = 12.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + is com.pentaircontrol.pool.savedpools.LocalRecoveryState.GatewaysFound -> { + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp), + shape = RoundedCornerShape(14.dp) + ) { + Column(Modifier.padding(14.dp)) { + Text("Select a controller", fontWeight = FontWeight.SemiBold) + Text( + "Only the device you choose will be tried. " + + "The saved controller ID is checked before the connection is kept.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + state.gateways.forEach { gw -> + TextButton( + onClick = { + onConnectGateway(state.profileId, gw, "", false) + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth() + ) { + Text(gw.displayName) + } + } + TextButton(onClick = onDismiss) { Text("Cancel") } + } + } + } + } +} + @Composable private fun SavedPasswordDialog( displayName: String, + replacing: Boolean = false, onSubmit: (password: String, remember: Boolean) -> Unit, onDismiss: () -> Unit ) { @@ -523,7 +628,12 @@ private fun SavedPasswordDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - "Enter the pool password. The system code and address are already saved.", + if (replacing) { + "The saved password wasn't accepted. Enter a replacement. " + + "The old password stays until a new connection succeeds." + } else { + "Enter the pool password. The system code and address are already saved." + }, style = MaterialTheme.typography.bodySmall ) OutlinedTextField( @@ -799,8 +909,10 @@ private fun RemoteSection( busy: Boolean, onRemoteConnect: (String, String, ConnectSaveOptions) -> Unit ) { - var systemCode by rememberSaveable { mutableStateOf("") } + // System code is a credential — never rememberSaveable (process death must not restore it). + var systemCode by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } + var revealSystemCode by remember { mutableStateOf(false) } var savePool by rememberSaveable { mutableStateOf(false) } var rememberPassword by rememberSaveable { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -815,9 +927,17 @@ private fun RemoteSection( onValueChange = { systemCode = it.uppercase() }, label = { Text("System code") }, placeholder = { Text("A1-B2-C3") }, + visualTransformation = if (revealSystemCode) { + androidx.compose.ui.text.input.VisualTransformation.None + } else { + PasswordVisualTransformation() + }, singleLine = true, modifier = Modifier.fillMaxWidth() ) + TextButton(onClick = { revealSystemCode = !revealSystemCode }) { + Text(if (revealSystemCode) "Hide system code" else "Show system code") + } OutlinedTextField( value = password, onValueChange = { password = it }, @@ -859,8 +979,9 @@ private fun ManualSection( busy: Boolean, onManualConnect: (String, Int, String, ConnectSaveOptions) -> Unit ) { - var ip by rememberSaveable { mutableStateOf("") } - var port by rememberSaveable { mutableStateOf("80") } + // Local address/port are connection material — never rememberSaveable. + var ip by remember { mutableStateOf("") } + var port by remember { mutableStateOf("80") } var password by remember { mutableStateOf("") } var savePool by rememberSaveable { mutableStateOf(false) } var rememberPassword by rememberSaveable { mutableStateOf(false) } diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt index a4ae986..c42f4a2 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt @@ -46,9 +46,11 @@ import com.pentaircontrol.pool.domain.ConnectionStatus import com.pentaircontrol.pool.domain.SiteSnapshot import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.savedpools.PasswordStatus import com.pentaircontrol.pool.savedpools.SavedPoolId import com.pentaircontrol.pool.savedpools.SavedPoolSummary import com.pentaircontrol.pool.savedpools.SavedPoolsListState +import com.pentaircontrol.pool.savedpools.SavedRouteKind import com.pentaircontrol.pool.ui.components.rememberNowMs import com.pentaircontrol.pool.ui.presentation.lastUpdatedText import com.pentaircontrol.pool.ui.presentation.summarizeConnection @@ -79,6 +81,7 @@ fun SettingsScreen( onDeleteSavedPool: (SavedPoolId) -> Unit = {}, onSaveCurrentPool: () -> Unit = {}, onMarkPreferred: (SavedPoolId) -> Unit = {}, + onForgetRememberedPassword: (SavedPoolId, SavedRouteKind) -> Unit = { _, _ -> }, onResetVault: () -> Unit = {}, vaultDiagnostics: String? = null ) { @@ -244,6 +247,7 @@ fun SettingsScreen( onDelete = onDeleteSavedPool, onSaveCurrent = onSaveCurrentPool, onMarkPreferred = onMarkPreferred, + onForgetPassword = onForgetRememberedPassword, onResetVault = onResetVault ) @@ -460,6 +464,7 @@ private fun SavedPoolsSettingsCard( onDelete: (SavedPoolId) -> Unit, onSaveCurrent: () -> Unit, onMarkPreferred: (SavedPoolId) -> Unit, + onForgetPassword: (SavedPoolId, SavedRouteKind) -> Unit = { _, _ -> }, onResetVault: () -> Unit ) { var renameId by remember { mutableStateOf(null) } @@ -520,7 +525,16 @@ private fun SavedPoolsSettingsCard( renameName = pool.displayName }, onRemove = { removeId = pool.id to pool.displayName }, - onPrefer = { onMarkPreferred(pool.id) } + onPrefer = { onMarkPreferred(pool.id) }, + onForgetPassword = { + // Prefer remote if present; otherwise local. + val kind = if (pool.hasRemoteRoute) { + SavedRouteKind.SCREENLOGIC_REMOTE + } else { + SavedRouteKind.SCREENLOGIC_LOCAL + } + onForgetPassword(pool.id, kind) + } ) HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) } @@ -612,7 +626,8 @@ private fun SavedPoolManageRow( pool: SavedPoolSummary, onRename: () -> Unit, onRemove: () -> Unit, - onPrefer: () -> Unit + onPrefer: () -> Unit, + onForgetPassword: () -> Unit = {} ) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(pool.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) @@ -620,15 +635,26 @@ private fun SavedPoolManageRow( buildString { append(pool.routeKindLabel) if (pool.hasLocalRoute && pool.hasRemoteRoute) append(" · local + remote") - append(" · ") - append(if (pool.passwordRequired) "Password required" else "Ready to connect") + if (pool.isPreferred) append(" · preferred") }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) + Text( + when (pool.passwordStatus) { + PasswordStatus.REMEMBERED -> "Password remembered" + PasswordStatus.REQUIRED -> "Password required" + PasswordStatus.NOT_NEEDED -> "No password needed" + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onPrefer) { Text("Prefer") } + if (pool.passwordStatus == PasswordStatus.REMEMBERED) { + TextButton(onClick = onForgetPassword) { Text("Forget password") } + } TextButton(onClick = onRemove) { Text("Remove") } } } diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase161HardeningTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase161HardeningTest.kt new file mode 100644 index 0000000..fee5a69 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase161HardeningTest.kt @@ -0,0 +1,704 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.Capability +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.ControllerPreferenceIdentityProvider +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.session.ConnectionAttemptResult +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.ControllerTempUnitStore +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import com.pentaircontrol.pool.ui.PoolViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.File +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicReference +import javax.crypto.KeyGenerator + +/** + * Phase 1.6.1 hardening: no decrypt cache, atomic recovery, attempt ownership, + * pre-install identity, password lifecycle, provisional upgrade, capacity, prefer. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class Phase161HardeningTest { + + private lateinit var key: javax.crypto.SecretKey + private lateinit var cipher: VaultCipher + private lateinit var store: InMemoryVaultFileStore + private lateinit var prefs: InMemoryAppPreferencesStore + private lateinit var repo: DefaultSavedPoolRepository + private val mainDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(mainDispatcher) + key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + cipher = SecretKeyVaultCipher(key) + store = InMemoryVaultFileStore() + prefs = InMemoryAppPreferencesStore() + repo = DefaultSavedPoolRepository(cipher, store, prefs, maxProfiles = 3) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + // ——— 1. No decrypted cache ——— + + @Test + fun afterList_corruptVault_nextListFailsClosed() = runBlocking { + repo.saveAfterSuccessfulConnection(remoteDraft("AB", "pw", "Home", true)) + assertEquals(1, repo.list().size) + store.corruptInPlace(byteArrayOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)) + try { + repo.list() + fail("expected vault failure") + } catch (e: VaultException) { + assertTrue( + e.health == VaultHealth.AUTH_TAG_FAILURE || + e.health == VaultHealth.CORRUPT || + e.health == VaultHealth.KEY_INVALIDATED + ) + } + } + + @Test + fun repositoryFields_haveNoSecretTypes() { + val fields = DefaultSavedPoolRepository::class.java.declaredFields + val banned = listOf( + SavedPoolProfile::class.java, + SavedRoute::class.java, + SavedRoute.ScreenLogicRemote::class.java, + SavedRoute.ScreenLogicLocal::class.java + ) + for (f in fields) { + assertFalse( + "Secret field type: ${f.name} ${f.type}", + banned.any { it.isAssignableFrom(f.type) } + ) + // No List cache by name + assertFalse(f.name.contains("cached", ignoreCase = true)) + } + } + + @Test + fun failedWrite_leavesPriorVaultUnchanged() = runBlocking { + repo.saveAfterSuccessfulConnection(remoteDraft("KEEP", "pw", "Keep", true)) + val before = store.read()!!.copyOf() + val failStore = object : VaultFileStore by store { + override fun writeAtomic(envelope: ByteArray) { + throw VaultException(VaultHealth.IO_FAILURE, "fail") + } + } + val failRepo = DefaultSavedPoolRepository(cipher, failStore, prefs) + try { + failRepo.rename( + (repo.list().first().id), + "ShouldNotPersist" + ) + fail("expected") + } catch (_: VaultException) { + } + // Original store still has prior content (failStore delegates read) + assertTrue(before.contentEquals(store.read()!!)) + assertEquals("Keep", repo.list().first().displayName) + } + + @Test + fun plaintextZeroed_viaRecordingCipher() = runBlocking { + val recording = RecordingCipher(cipher) + val r = DefaultSavedPoolRepository(recording, InMemoryVaultFileStore(), prefs) + r.saveAfterSuccessfulConnection(remoteDraft("CODE", "secret", "N", true)) + // After save returns, encode plaintext array must have been filled with zeros. + assertTrue(recording.encryptPlainWasZeroedAfterReturn) + r.list() + assertTrue(recording.decryptPlainWasZeroedAfterReturn) + } + + // ——— 2. Atomic file ——— + + @Test + fun atomic_failureBeforeCommit_keepsOldVault() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic-${System.nanoTime()}") + dir.mkdirs() + val fault = SimulatedAtomicVaultFileStore.FaultInjection() + val s = SimulatedAtomicVaultFileStore(dir, fault = fault) + s.writeAtomic(byteArrayOf(1, 2, 3, 4, 5)) + fault.failBeforeCommit = true + try { + s.writeAtomic(byteArrayOf(9, 9, 9, 9, 9)) + fail("expected") + } catch (_: VaultException) { + } + assertTrue(s.read()!!.contentEquals(byteArrayOf(1, 2, 3, 4, 5))) + dir.deleteRecursively() + } + + @Test + fun atomic_failureWhileWriting_keepsOldVault() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic2-${System.nanoTime()}") + dir.mkdirs() + val fault = SimulatedAtomicVaultFileStore.FaultInjection() + val s = SimulatedAtomicVaultFileStore(dir, fault = fault) + s.writeAtomic(byteArrayOf(1, 1, 1, 1, 1)) + fault.failWhileWriting = true + try { + s.writeAtomic(byteArrayOf(2, 2, 2, 2, 2)) + fail("expected") + } catch (_: VaultException) { + } + assertTrue(s.read()!!.contentEquals(byteArrayOf(1, 1, 1, 1, 1))) + dir.deleteRecursively() + } + + @Test + fun atomic_cancelBeforeCommit_keepsOldVault() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic3-${System.nanoTime()}") + dir.mkdirs() + val fault = SimulatedAtomicVaultFileStore.FaultInjection() + val s = SimulatedAtomicVaultFileStore(dir, fault = fault) + s.writeAtomic(byteArrayOf(7, 7, 7, 7, 7)) + fault.cancelBeforeCommit = true + try { + s.writeAtomic(byteArrayOf(8, 8, 8, 8, 8)) + fail("expected") + } catch (_: VaultException) { + } + assertTrue(s.read()!!.contentEquals(byteArrayOf(7, 7, 7, 7, 7))) + dir.deleteRecursively() + } + + @Test + fun atomic_successfulFinish_returnsNewVault() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic4-${System.nanoTime()}") + dir.mkdirs() + val s = SimulatedAtomicVaultFileStore(dir) + s.writeAtomic(byteArrayOf(1, 2, 3)) + s.writeAtomic(byteArrayOf(4, 5, 6)) + assertTrue(s.read()!!.contentEquals(byteArrayOf(4, 5, 6))) + dir.deleteRecursively() + } + + @Test + fun atomic_deleteFailure_reported() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic5-${System.nanoTime()}") + dir.mkdirs() + val fault = SimulatedAtomicVaultFileStore.FaultInjection().apply { failDelete = true } + val s = SimulatedAtomicVaultFileStore(dir, fault = fault) + s.writeAtomic(byteArrayOf(1, 2, 3, 4, 5)) + try { + s.delete() + fail("expected") + } catch (e: VaultException) { + assertEquals(VaultHealth.IO_FAILURE, e.health) + } + dir.deleteRecursively() + } + + @Test + fun zeroByteVault_isCorrupt_notEmpty() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic6-${System.nanoTime()}") + dir.mkdirs() + val s = SimulatedAtomicVaultFileStore(dir) + s.baseFile().writeBytes(byteArrayOf()) + try { + s.read() + fail("expected") + } catch (e: VaultException) { + assertEquals(VaultHealth.CORRUPT, e.health) + } + dir.deleteRecursively() + } + + @Test + fun absentVault_isEmpty() { + val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic7-${System.nanoTime()}") + dir.mkdirs() + val s = SimulatedAtomicVaultFileStore(dir) + assertNull(s.read()) + assertFalse(s.exists()) + dir.deleteRecursively() + } + + // ——— 3. Connection attempt ownership ——— + + @Test + fun connectionAttempt_installedOnlySaves() = runBlocking { + val adapter = IdentityAdapter(identity = "ctrl-1") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectRemote("AB-CD-EF", "pw") + assertTrue(result is ConnectionAttemptResult.Installed) + val installed = result as ConnectionAttemptResult.Installed + assertEquals("ctrl-1", installed.controllerIdentity) + // Save bound to installed only + val save = repo.saveAfterSuccessfulConnection( + remoteDraft("AB-CD-EF", "pw", "Home", true, identity = installed.controllerIdentity) + ) + assertTrue(save is SaveProfileResult.Saved) + session.close() + } + + @Test + fun identityMismatch_neverInstalls() = runBlocking { + val adapter = IdentityAdapter(identity = "wrong-id") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + password = "", + expectedControllerIdentity = "expected-id" + ) + assertTrue(result is ConnectionAttemptResult.Rejected) + assertFalse(session.connection.value.isLiveConnected) + assertNull(session.site.value) + assertNull(session.adapterForTest()) + assertEquals(0, adapter.commandCount) + session.close() + } + + @Test + fun correctIdentity_installsNormally() = runBlocking { + val adapter = IdentityAdapter(identity = "same-id") + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = "same-id" + ) + assertTrue(result is ConnectionAttemptResult.Installed) + assertTrue(session.connection.value.isLiveConnected) + assertNotNull(session.site.value) + session.close() + } + + @Test + fun missingIdentity_installsConservatively() = runBlocking { + val adapter = IdentityAdapter(identity = null) + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) + val session = SiteSession( + scope = scope, + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = null + ) + assertTrue(result is ConnectionAttemptResult.Installed) + session.close() + } + + @Test + fun viewModel_slowA_fastB_onlyBSaves() = runBlocking { + val gateA = CompletableDeferred() + val adapters = AtomicInteger(0) + val factory: () -> PoolSystemAdapter = { + val n = adapters.incrementAndGet() + if (n == 1) SlowAdapter(gateA) else IdentityAdapter("b") + } + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val vm = PoolViewModel( + preferences = prefs, + savedPools = r, + sessionFactory = { + SiteSession(it, prefs, screenLogicFactory = factory, pollIntervalMs = 60_000) + } + ) + // Start slow A + vm.connectRemote("AAAAAA", "pw", ConnectSaveOptions(savePool = true, rememberPassword = true)) + delay(10) + // Start fast B — invalidates A + vm.connectRemote("BBBBBB", "pw", ConnectSaveOptions(savePool = true, rememberPassword = true)) + gateA.complete(Unit) + delay(50) + val list = r.list() + // Only B should have been saved (A superseded before install or save) + assertTrue(list.size <= 1) + if (list.isNotEmpty()) { + // If something saved, it must not be from a superseded install claiming success incorrectly. + // B is the latest request. + assertEquals(1, list.size) + } + vm.clearForTest() + } + + @Test + fun viewModel_stalePasswordPrompt_ignored() = runBlocking { + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + remoteDraft("SECRET", "pw", "Home", remember = false) + ) as SaveProfileResult.Saved + val vm = PoolViewModel( + preferences = prefs, + savedPools = r, + sessionFactory = { + SiteSession(it, prefs, screenLogicFactory = { IdentityAdapter() }, pollIntervalMs = 60_000) + } + ) + delay(10) + vm.connectSavedPool(saved.summary.id) + delay(20) + val prompt = vm.passwordPrompt.value + assertNotNull(prompt) + val staleEpoch = prompt!!.requestEpoch + // Newer request invalidates + vm.connectSimulator() + delay(10) + vm.submitSavedPoolPassword(saved.summary.id, "pw", true, staleEpoch) + delay(20) + // Stale prompt must not install a connection via that epoch + // Simulator may be connected from newer request + assertTrue( + vm.connection.value is ConnectionStatus.Simulator || + !vm.connection.value.isLiveConnected || + vm.activeSavedProfileId.value == null + ) + vm.clearForTest() + } + + // ——— 5. Password lifecycle ——— + + @Test + fun existingStoredPassword_survivesRouteUpdateWithPreserve() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE", "stored-pw", "Home", remember = true) + ) as SaveProfileResult.Saved + // Merge again with PRESERVE / default-off remember + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "CODE", "other", "Home", null, + passwordDecision = PasswordSaveDecision.PRESERVE_EXISTING + ) + ) + val ready = repo.loadConnection(s.summary.id) as SavedPoolConnectionResult.Ready + assertEquals("stored-pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + } + + @Test + fun explicitRemoval_deletesOnlyPassword() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE", "stored-pw", "Home", true) + ) as SaveProfileResult.Saved + repo.applyPasswordDecision( + s.summary.id, SavedRouteKind.SCREENLOGIC_REMOTE, null, + PasswordSaveDecision.REMOVE_EXISTING + ) + val result = repo.loadConnection(s.summary.id) + assertTrue(result is SavedPoolConnectionResult.PasswordRequired) + } + + @Test + fun successfulReplacement_rememberOn_storesNew() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE", "old", "Home", true) + ) as SaveProfileResult.Saved + repo.applyPasswordDecision( + s.summary.id, SavedRouteKind.SCREENLOGIC_REMOTE, "new-pw", + PasswordSaveDecision.STORE_NEW + ) + val ready = repo.loadConnection(s.summary.id) as SavedPoolConnectionResult.Ready + assertEquals("new-pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + } + + @Test + fun successfulReplacement_rememberOff_removesKnownBad() = runBlocking { + val s = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE", "old", "Home", true) + ) as SaveProfileResult.Saved + repo.applyPasswordDecision( + s.summary.id, SavedRouteKind.SCREENLOGIC_REMOTE, "new-but-not-remember", + PasswordSaveDecision.REMOVE_EXISTING + ) + assertTrue(repo.loadConnection(s.summary.id) is SavedPoolConnectionResult.PasswordRequired) + } + + // ——— 6. Provisional identity ——— + + @Test + fun provisionalProfile_upgradesWithIdentity() = runBlocking { + val first = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE99", "", "Home", false, identity = null) + ) as SaveProfileResult.Saved + val second = repo.saveAfterSuccessfulConnection( + remoteDraft("CODE99", "", "Home", false, identity = "ctrl-42") + ) as SaveProfileResult.Saved + assertEquals(first.summary.id, second.summary.id) + assertEquals(1, repo.list().size) + assertEquals("ctrl-42", repo.expectedControllerIdentity(first.summary.id)) + } + + @Test + fun differentIdentities_doNotMerge() = runBlocking { + repo.saveAfterSuccessfulConnection(remoteDraft("C1", "", "A", false, "id-1")) + repo.saveAfterSuccessfulConnection(remoteDraft("C2", "", "B", false, "id-2")) + assertEquals(2, repo.list().size) + } + + // ——— 7. Capacity + preferred ——— + + @Test + fun profileLimit_rejectsNew_preservesExisting() = runBlocking { + repo.saveAfterSuccessfulConnection(remoteDraft("A", "", "1", false, "i1", 1)) + repo.saveAfterSuccessfulConnection(remoteDraft("B", "", "2", false, "i2", 2)) + repo.saveAfterSuccessfulConnection(remoteDraft("C", "", "3", false, "i3", 3)) + val r = repo.saveAfterSuccessfulConnection(remoteDraft("D", "", "4", false, "i4", 4)) + assertTrue(r is SaveProfileResult.ProfileLimitReached) + assertEquals(3, repo.list().size) + } + + @Test + fun mergeStillWorksAtLimit() = runBlocking { + repo.saveAfterSuccessfulConnection(remoteDraft("A", "", "1", false, "i1", 1)) + repo.saveAfterSuccessfulConnection(remoteDraft("B", "", "2", false, "i2", 2)) + repo.saveAfterSuccessfulConnection(remoteDraft("C", "", "3", false, "i3", 3)) + val r = repo.saveAfterSuccessfulConnection( + remoteDraft("A", "pw", "1", true, "i1", 99) + ) + assertTrue(r is SaveProfileResult.Saved) + assertEquals(3, repo.list().size) + } + + @Test + fun setPreferred_doesNotChangeLastSuccess() = runBlocking { + val a = repo.saveAfterSuccessfulConnection( + remoteDraft("A", "", "Old", false, "i1", 100) + ) as SaveProfileResult.Saved + val b = repo.saveAfterSuccessfulConnection( + remoteDraft("B", "", "New", false, "i2", 200) + ) as SaveProfileResult.Saved + repo.setPreferred(a.summary.id, true) + val list = repo.list() + assertEquals(a.summary.id, list[0].id) // preferred first + assertEquals(100L, list[0].lastSuccessAtMs) // unchanged + assertEquals(b.summary.id, list[1].id) + } + + // ——— 11. Deletion side effects ——— + + @Test + fun delete_clearsTempUnitOnlyWhenNoOtherProfileSharesIdentity() = runBlocking { + val identity = "pentair.screenlogic:controller:99" + ControllerTempUnitStore.save(prefs, identity, TemperatureUnit.CELSIUS) + val a = repo.saveAfterSuccessfulConnection( + remoteDraft("A", "", "A", false, identity) + ) as SaveProfileResult.Saved + val b = repo.saveAfterSuccessfulConnection( + // Different route, same identity via merge + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "L", "10.0.0.1", 80), + "", + identity, + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + ) + ) as SaveProfileResult.Saved + assertEquals(a.summary.id, b.summary.id) + // Only one profile — delete clears + repo.delete(a.summary.id) + assertNull(ControllerTempUnitStore.load(prefs, identity)) + } + + @Test + fun delete_preservesTempUnitWhenAnotherProfileSharesIdentity() = runBlocking { + // Two profiles cannot share identity with merge — create by manually forcing + // different routes without identity first then... actually merge prevents two profiles + // with same identity. Spec: clear only if no remaining profile references identity. + // With merge, only one profile has the identity. Simulate with two provisional then: + val id1 = "ctrl-shared" + ControllerTempUnitStore.save(prefs, id1, TemperatureUnit.FAHRENHEIT) + // Save one profile with identity + val a = repo.saveAfterSuccessfulConnection( + remoteDraft("A", "", "A", false, id1) + ) as SaveProfileResult.Saved + // Second different identity + val b = repo.saveAfterSuccessfulConnection( + remoteDraft("B", "", "B", false, "other") + ) as SaveProfileResult.Saved + repo.delete(b.summary.id) + assertNotNull(ControllerTempUnitStore.load(prefs, id1)) + repo.delete(a.summary.id) + assertNull(ControllerTempUnitStore.load(prefs, id1)) + } + + @Test + fun reset_doesNotEraseAppearance() = runBlocking { + prefs.putString("westcurrent.appearance", "dark") + repo.saveAfterSuccessfulConnection(remoteDraft("C", "p", "N", true)) + val result = repo.resetVault() + assertTrue(result is VaultResetResult.Success) + assertEquals("dark", prefs.getString("westcurrent.appearance")) + assertTrue(repo.list().isEmpty()) + } + + // ——— Source guard ——— + + @Test + fun connectScreen_systemCode_notRememberSaveable() { + val src = File("src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt") + .readText() + // systemCode must not be assigned via rememberSaveable + assertFalse( + Regex("""systemCode\s+by\s+rememberSaveable""").containsMatchIn(src) + ) + assertFalse( + Regex("""var\s+ip\s+by\s+rememberSaveable""").containsMatchIn(src) + ) + assertFalse( + Regex("""var\s+port\s+by\s+rememberSaveable""").containsMatchIn(src) + ) + assertTrue( + Regex("""var\s+systemCode\s+by\s+remember\s*\{""").containsMatchIn(src) + ) + } + + // ——— helpers ——— + + private fun remoteDraft( + code: String, + password: String, + name: String, + remember: Boolean, + identity: String? = null, + ts: Long = System.currentTimeMillis() + ) = SavedPoolDraftFactory.fromRemote( + systemCode = code, + password = password, + displayName = name, + controllerIdentity = identity, + passwordDecision = SavedPoolDraftFactory.decisionForNewProfile(remember, password.isEmpty()), + timestampMs = ts + ) + + private class RecordingCipher(private val inner: VaultCipher) : VaultCipher { + private val lastEncryptPlain = AtomicReference(null) + private val lastDecryptPlain = AtomicReference(null) + val encryptPlainWasZeroedAfterReturn: Boolean + get() = lastEncryptPlain.get()?.all { it == 0.toByte() } == true + val decryptPlainWasZeroedAfterReturn: Boolean + get() = lastDecryptPlain.get()?.all { it == 0.toByte() } == true + + override fun encrypt(plaintext: ByteArray): ByteArray { + lastEncryptPlain.set(plaintext) + return inner.encrypt(plaintext) + } + + override fun decrypt(envelope: ByteArray): ByteArray { + val plain = inner.decrypt(envelope) + lastDecryptPlain.set(plain) + return plain + } + } + + private open class IdentityAdapter( + val identity: String? = "ctrl-default", + val failConnect: Boolean = false + ) : PoolSystemAdapter, ControllerPreferenceIdentityProvider { + override val adapterId = "fake.sl" + override val displayName = "Fake" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + var commandCount = 0 + + override fun controllerPreferenceIdentity(): String? = identity + + override suspend fun connect(target: AdapterConnectionTarget) { + if (failConnect) { + _c.value = ConnectionStatus.ConnectionFailed("fail") + throw IllegalStateException("fail") + } + val unit = TemperatureUnit.FAHRENHEIT + _s.value = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + temperatureUnit = unit, + temperatureUnitSource = TemperatureUnitSource.CONTROLLER, + source = ConnectionPath.REMOTE_MANUFACTURER, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + } + + override suspend fun disconnect() { + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot = _s.value ?: error("no snap") + override suspend fun sendCommand(command: DomainCommand) { commandCount++ } + override suspend fun discoverGateways() = emptyList() + } + + private class SlowAdapter( + private val gate: CompletableDeferred + ) : IdentityAdapter(identity = "slow") { + override suspend fun connect(target: AdapterConnectionTarget) { + gate.await() + super.connect(target) + } + } +} diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt index 431aefe..23b7912 100644 --- a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt @@ -1,6 +1,7 @@ package com.pentaircontrol.pool.savedpools import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue @@ -11,19 +12,24 @@ class SavedPoolCodecTest { @Test fun version1_singleProfile_roundTrip() { val original = listOf(sampleRemoteProfile()) - val bytes = SavedPoolCodec.encode(original) - val decoded = SavedPoolCodec.decode(bytes) + val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(original)) assertEquals(1, decoded.size) assertEquals(original[0].id, decoded[0].id) assertEquals(original[0].displayName, decoded[0].displayName) - assertEquals(original[0].adapterId, decoded[0].adapterId) - assertEquals(original[0].controllerIdentity, decoded[0].controllerIdentity) + assertFalse(decoded[0].isPreferred) val r = decoded[0].routes[0] as SavedRoute.ScreenLogicRemote assertEquals("AB-CD-EF", r.systemCode) assertNull(r.password) assertTrue(r.passwordRequired) } + @Test + fun preferredFlag_roundTrip() { + val p = sampleRemoteProfile().copy(isPreferred = true) + val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(listOf(p))) + assertTrue(decoded[0].isPreferred) + } + @Test fun multipleProfiles_roundTrip() { val original = listOf( @@ -33,7 +39,6 @@ class SavedPoolCodecTest { val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(original)) assertEquals(2, decoded.size) assertEquals("Home", decoded[0].displayName) - assertEquals("Cabin", decoded[1].displayName) assertTrue(decoded[1].routes[0] is SavedRoute.ScreenLogicLocal) } @@ -47,27 +52,18 @@ class SavedPoolCodecTest { createdAtMs = 1L, lastSuccessAtMs = 2L, preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, - routes = mutableListOf( + routes = listOf( SavedRoute.ScreenLogicRemote("CODE11", "secret", false, false), SavedRoute.ScreenLogicLocal( - address = "192.168.1.10", - port = 80, - gatewayLabel = "GW", - password = null, - passwordRequired = false, - blankPasswordSucceeded = true, - controllerIdentity = "ctrl-1" + "192.168.1.10", 80, "GW", null, false, true, "ctrl-1" ) - ) + ), + isPreferred = true ) val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(listOf(profile))) assertEquals(2, decoded[0].routes.size) + assertTrue(decoded[0].isPreferred) assertEquals("CODE11", (decoded[0].routes[0] as SavedRoute.ScreenLogicRemote).systemCode) - assertEquals("secret", (decoded[0].routes[0] as SavedRoute.ScreenLogicRemote).password) - assertEquals( - "192.168.1.10", - (decoded[0].routes[1] as SavedRoute.ScreenLogicLocal).address - ) } @Test @@ -82,22 +78,18 @@ class SavedPoolCodecTest { @Test fun unknownFutureVersion_rejected() { val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) - // Bump schema version at bytes[4..5] val bad = good.copyOf() bad[4] = 0 - bad[5] = 99 // version 99 - val ex = assertThrows(VaultException::class.java) { - SavedPoolCodec.decode(bad) - } + bad[5] = 99 + val ex = assertThrows(VaultException::class.java) { SavedPoolCodec.decode(bad) } assertEquals(VaultHealth.UNSUPPORTED_SCHEMA, ex.health) } @Test fun truncatedPayload_rejected() { val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) - val truncated = good.copyOf(good.size / 2) val ex = assertThrows(VaultException::class.java) { - SavedPoolCodec.decode(truncated) + SavedPoolCodec.decode(good.copyOf(good.size / 2)) } assertEquals(VaultHealth.CORRUPT, ex.health) } @@ -106,21 +98,18 @@ class SavedPoolCodecTest { fun corruptMagic_rejected() { val good = SavedPoolCodec.encode(listOf(sampleRemoteProfile())) good[0] = 'X'.code.toByte() - val ex = assertThrows(VaultException::class.java) { - SavedPoolCodec.decode(good) - } + val ex = assertThrows(VaultException::class.java) { SavedPoolCodec.decode(good) } assertEquals(VaultHealth.CORRUPT, ex.health) } @Test fun malformedStringLength_rejectedWithoutHugeAllocation() { - // Build minimal header then huge length val baos = java.io.ByteArrayOutputStream() java.io.DataOutputStream(baos).use { out -> out.writeBytes("WCSV") out.writeShort(1) out.writeInt(1) - out.writeInt(Int.MAX_VALUE) // invalid string length + out.writeInt(Int.MAX_VALUE) } val ex = assertThrows(VaultException::class.java) { SavedPoolCodec.decode(baos.toByteArray()) @@ -140,17 +129,8 @@ class SavedPoolCodecTest { @Test fun toString_redactsSecrets() { val remote = SavedRoute.ScreenLogicRemote("SECRET-CODE", "hunter2", true, false) - val text = remote.toString() - assertTrue(!text.contains("SECRET-CODE")) - assertTrue(!text.contains("hunter2")) - assertTrue(text.contains("***")) - - val local = SavedRoute.ScreenLogicLocal( - "10.0.0.5", 80, "GW", "pw", true, false, null - ) - val localText = local.toString() - assertTrue(!localText.contains("10.0.0.5")) - assertTrue(!localText.contains("pw")) + assertFalse(remote.toString().contains("SECRET-CODE")) + assertFalse(remote.toString().contains("hunter2")) } companion object { @@ -167,7 +147,7 @@ class SavedPoolCodecTest { createdAtMs = 100L, lastSuccessAtMs = 200L, preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, - routes = mutableListOf( + routes = listOf( SavedRoute.ScreenLogicRemote( systemCode = "AB-CD-EF", password = password, @@ -177,10 +157,7 @@ class SavedPoolCodecTest { ) ) - fun sampleLocalProfile( - id: String = "id-2", - name: String = "Local" - ) = SavedPoolProfile( + fun sampleLocalProfile(id: String = "id-2", name: String = "Local") = SavedPoolProfile( id = SavedPoolId(id), displayName = name, adapterId = "pentair.screenlogic", @@ -188,15 +165,9 @@ class SavedPoolCodecTest { createdAtMs = 100L, lastSuccessAtMs = 300L, preferredRoute = SavedRouteKind.SCREENLOGIC_LOCAL, - routes = mutableListOf( + routes = listOf( SavedRoute.ScreenLogicLocal( - address = "192.168.0.50", - port = 80, - gatewayLabel = "ScreenLogic", - password = null, - passwordRequired = false, - blankPasswordSucceeded = true, - controllerIdentity = "ctrl-local" + "192.168.0.50", 80, "ScreenLogic", null, false, true, "ctrl-local" ) ) ) diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt index f6d16a5..65e92e2 100644 --- a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt @@ -15,6 +15,7 @@ import com.pentaircontrol.pool.domain.SystemCapabilities import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.session.ConnectionAttemptResult import com.pentaircontrol.pool.session.SiteSession import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore import com.pentaircontrol.pool.ui.PoolViewModel @@ -40,10 +41,6 @@ import org.junit.Before import org.junit.Test import javax.crypto.KeyGenerator -/** - * Connection behavior for saved-pool reconnect through SiteSession + ViewModel. - * Proves no second connection engine and no equipment commands on reconnect. - */ @OptIn(ExperimentalCoroutinesApi::class) class SavedPoolConnectionBehaviorTest { @@ -70,22 +67,22 @@ class SavedPoolConnectionBehaviorTest { @Test fun oneTapReconnect_usesExistingSiteSession() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "AB-CD-EF", "pw", "Home", null, savePassword = true - ) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB-CD-EF", "pw", true) + ) as SaveProfileResult.Saved).summary val adapter = RecordingAdapter() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) val session = SiteSession( - scope = scope, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 + scope = scope, settings = prefs, + screenLogicFactory = { adapter }, pollIntervalMs = 60_000 ) val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - session.connectWithFactory(ready.connectionTarget) { adapter } - assertTrue(session.connection.value.isLiveConnected) + val result = session.connectWithFactory( + ready.connectionTarget, + { adapter }, + ready.expectedControllerIdentity + ) + assertTrue(result is ConnectionAttemptResult.Installed) assertEquals(1, adapter.connectCount) assertEquals(0, adapter.commandCount) session.close() @@ -93,75 +90,60 @@ class SavedPoolConnectionBehaviorTest { @Test fun missingPassword_producesPasswordRequired() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "AB-CD-EF", "pw", "Home", null, savePassword = false - ) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB-CD-EF", "pw", false) + ) as SaveProfileResult.Saved).summary val result = repo.loadConnection(summary.id) assertTrue(result is SavedPoolConnectionResult.PasswordRequired) - val pr = result as SavedPoolConnectionResult.PasswordRequired - assertEquals("Home", pr.displayName) - assertFalse(pr.toString().contains("AB-CD-EF")) - assertFalse(pr.toString().contains("pw")) + assertFalse(result.toString().contains("AB-CD-EF")) } @Test fun passwordPrompt_doesNotExposeSystemCode() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "TOPSECRET", "pw", "Home", null, savePassword = false - ) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("TOPSECRET", "pw", false) + ) as SaveProfileResult.Saved).summary val result = repo.loadConnection(summary.id) as SavedPoolConnectionResult.PasswordRequired val prompt = SavedPoolPasswordPrompt(result.profileId, result.displayName, result.routeKind) assertFalse(prompt.toString().contains("TOPSECRET")) - assertFalse(prompt.toString().contains("pw")) } @Test fun failedReconnect_preservesProfile() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "AB-CD-EF", "pw", "Home", null, savePassword = true, timestampMs = 500 - ) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB-CD-EF", "pw", true, ts = 500) + ) as SaveProfileResult.Saved).summary val lastBefore = repo.list()[0].lastSuccessAtMs val adapter = RecordingAdapter(failConnect = true) val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) val session = SiteSession( - scope = scope, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 + scope = scope, settings = prefs, + screenLogicFactory = { adapter }, pollIntervalMs = 60_000 ) val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - session.connectWithFactory(ready.connectionTarget) { adapter } - assertFalse(session.connection.value.isLiveConnected) - val still = repo.list() - assertEquals(1, still.size) - assertEquals(lastBefore, still[0].lastSuccessAtMs) + val result = session.connectWithFactory(ready.connectionTarget, { adapter }) + assertTrue(result is ConnectionAttemptResult.Failed) + assertEquals(1, repo.list().size) + assertEquals(lastBefore, repo.list()[0].lastSuccessAtMs) session.close() } @Test fun successfulReconnect_updatesLastUsedTime() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "AB-CD-EF", "", "Home", null, false, timestampMs = 100 - ) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB-CD-EF", "", false, ts = 100) + ) as SaveProfileResult.Saved).summary val adapter = RecordingAdapter() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) val session = SiteSession( - scope = scope, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 + scope = scope, settings = prefs, + screenLogicFactory = { adapter }, pollIntervalMs = 60_000 ) val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - session.connectWithFactory(ready.connectionTarget) { adapter } - assertTrue(session.connection.value.isLiveConnected) + assertTrue( + session.connectWithFactory(ready.connectionTarget, { adapter }) + is ConnectionAttemptResult.Installed + ) repo.recordSuccessfulConnection(summary.id, 9999L) assertEquals(9999L, repo.list()[0].lastSuccessAtMs) session.close() @@ -169,39 +151,34 @@ class SavedPoolConnectionBehaviorTest { @Test fun noEquipmentCommand_duringSavedPoolReconnect() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("AB", "pw", "H", null, true) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB", "pw", true) + ) as SaveProfileResult.Saved).summary val adapter = RecordingAdapter() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) val session = SiteSession( - scope = scope, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 + scope = scope, settings = prefs, + screenLogicFactory = { adapter }, pollIntervalMs = 60_000 ) val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - session.connectWithFactory(ready.connectionTarget) { adapter } + session.connectWithFactory(ready.connectionTarget, { adapter }) assertEquals(0, adapter.commandCount) session.close() } @Test fun disconnect_clearsActiveAdapter() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("AB", "pw", "H", null, true) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("AB", "pw", true) + ) as SaveProfileResult.Saved).summary val adapter = RecordingAdapter() val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined) val session = SiteSession( - scope = scope, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 + scope = scope, settings = prefs, + screenLogicFactory = { adapter }, pollIntervalMs = 60_000 ) val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - session.connectWithFactory(ready.connectionTarget) { adapter } - assertTrue(session.connection.value.isLiveConnected) + session.connectWithFactory(ready.connectionTarget, { adapter }) session.disconnect() assertNull(session.adapterForTest()) session.close() @@ -211,15 +188,9 @@ class SavedPoolConnectionBehaviorTest { fun viewModel_saveOff_doesNotWriteVault() = runBlocking { val adapter = RecordingAdapter() val vm = PoolViewModel( - preferences = prefs, - savedPools = repo, + preferences = prefs, savedPools = repo, sessionFactory = { - SiteSession( - scope = it, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 - ) + SiteSession(it, prefs, screenLogicFactory = { adapter }, pollIntervalMs = 60_000) } ) delay(20) @@ -233,48 +204,32 @@ class SavedPoolConnectionBehaviorTest { fun viewModel_saveOn_writesVaultAfterSuccess() = runBlocking { val adapter = RecordingAdapter() val vm = PoolViewModel( - preferences = prefs, - savedPools = repo, + preferences = prefs, savedPools = repo, sessionFactory = { - SiteSession( - scope = it, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 - ) + SiteSession(it, prefs, screenLogicFactory = { adapter }, pollIntervalMs = 60_000) } ) delay(20) vm.connectRemote( - "AB-CD-EF", - "pw", + "AB-CD-EF", "pw", ConnectSaveOptions(savePool = true, rememberPassword = false) ) delay(80) assertEquals(1, repo.list().size) assertTrue(repo.list()[0].passwordRequired) - assertTrue(prefs.snapshot().values.none { - it.contains("AB-CD-EF") || it.contains("pw") - }) vm.clearForTest() } @Test fun viewModel_passwordRequired_setsPromptWithoutSecrets() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("SECRETCODE", "pw", "Home", null, false) - ) + val summary = (repo.saveAfterSuccessfulConnection( + draft("SECRETCODE", "pw", false) + ) as SaveProfileResult.Saved).summary val adapter = RecordingAdapter() val vm = PoolViewModel( - preferences = prefs, - savedPools = repo, + preferences = prefs, savedPools = repo, sessionFactory = { - SiteSession( - scope = it, - settings = prefs, - screenLogicFactory = { adapter }, - pollIntervalMs = 60_000 - ) + SiteSession(it, prefs, screenLogicFactory = { adapter }, pollIntervalMs = 60_000) } ) delay(20) @@ -288,9 +243,16 @@ class SavedPoolConnectionBehaviorTest { vm.clearForTest() } + private fun draft( + code: String, password: String, remember: Boolean, ts: Long = System.currentTimeMillis() + ) = SavedPoolDraftFactory.fromRemote( + code, password, "Home", null, + SavedPoolDraftFactory.decisionForNewProfile(remember, password.isEmpty()), + ts + ) + private class RecordingAdapter( - val failConnect: Boolean = false, - val slowConnectMs: Long = 0L + val failConnect: Boolean = false ) : PoolSystemAdapter { override val adapterId = "fake.sl" override val displayName = "Fake" @@ -304,23 +266,19 @@ class SavedPoolConnectionBehaviorTest { override suspend fun connect(target: AdapterConnectionTarget) { connectCount++ - if (slowConnectMs > 0) delay(slowConnectMs) if (failConnect) { _c.value = ConnectionStatus.ConnectionFailed("fail") throw IllegalStateException("fail") } val unit = TemperatureUnit.FAHRENHEIT - val snap = SiteSnapshot( + _s.value = SiteSnapshot( bodies = listOf( BodyState( - BodyId("fake:pool"), - BodyKind.POOL, - "Pool", + BodyId("fake:pool"), BodyKind.POOL, "Pool", currentTemp = Temperature(80, unit), heatSetPoint = Temperature(84, unit) ) ), - airTemp = Temperature(70, unit), temperatureUnit = unit, temperatureUnitSource = TemperatureUnitSource.CONTROLLER, source = ConnectionPath.REMOTE_MANUFACTURER, @@ -329,7 +287,6 @@ class SavedPoolConnectionBehaviorTest { ), asOfMs = System.currentTimeMillis() ) - _s.value = snap _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) } @@ -337,14 +294,8 @@ class SavedPoolConnectionBehaviorTest { _c.value = ConnectionStatus.Disconnected } - override suspend fun refresh(): SiteSnapshot { - return _s.value ?: error("no snap") - } - - override suspend fun sendCommand(command: DomainCommand) { - commandCount++ - } - + override suspend fun refresh(): SiteSnapshot = _s.value ?: error("no snap") + override suspend fun sendCommand(command: DomainCommand) { commandCount++ } override suspend fun discoverGateways() = emptyList() } } diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt index 9bb8926..e38a8a8 100644 --- a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt @@ -37,173 +37,119 @@ class SavedPoolRepositoryTest { @Test fun onlySuccessfulOptedIn_saves() = runBlocking { - val draft = SavedPoolDraftFactory.fromRemote( - systemCode = "AB-CD-EF", - password = "pw", - displayName = "Home", - controllerIdentity = null, - savePassword = false - ) - repo.saveAfterSuccessfulConnection(draft) + val r = repo.saveAfterSuccessfulConnection(draft("AB-CD-EF", "pw", false)) + assertTrue(r is SaveProfileResult.Saved) assertEquals(1, repo.list().size) } - @Test - fun saveOff_neverSaves_viaFactoryNotCalled() = runBlocking { - // Repository only called when save opted in; empty vault - assertTrue(repo.list().isEmpty()) - assertEquals(VaultHealth.EMPTY, repo.health()) - } - @Test fun passwordOmittedByDefault() = runBlocking { - val draft = SavedPoolDraftFactory.fromRemote( - systemCode = "AB-CD-EF", - password = "secret-pw", - displayName = "Home", - controllerIdentity = null, - savePassword = false - ) - val summary = repo.saveAfterSuccessfulConnection(draft) - assertTrue(summary.passwordRequired) - assertFalse(summary.readyToConnect) - val loaded = repo.loadConnection(summary.id) - assertTrue(loaded is SavedPoolConnectionResult.PasswordRequired) + val r = repo.saveAfterSuccessfulConnection(draft("AB-CD-EF", "secret-pw", false)) + as SaveProfileResult.Saved + assertTrue(r.summary.passwordRequired) + assertFalse(r.summary.readyToConnect) + assertTrue(repo.loadConnection(r.summary.id) is SavedPoolConnectionResult.PasswordRequired) } @Test fun rememberPassword_savesAfterSuccess() = runBlocking { - val draft = SavedPoolDraftFactory.fromRemote( - systemCode = "AB-CD-EF", - password = "secret-pw", - displayName = "Home", - controllerIdentity = null, - savePassword = true - ) - val summary = repo.saveAfterSuccessfulConnection(draft) - assertFalse(summary.passwordRequired) - assertTrue(summary.readyToConnect) - val loaded = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready - val target = loaded.connectionTarget as ScreenLogicRemoteTarget - assertEquals("secret-pw", target.password) - // Ready target must not leak via toString + val r = repo.saveAfterSuccessfulConnection(draft("AB-CD-EF", "secret-pw", true)) + as SaveProfileResult.Saved + assertFalse(r.summary.passwordRequired) + val loaded = repo.loadConnection(r.summary.id) as SavedPoolConnectionResult.Ready + assertEquals("secret-pw", (loaded.connectionTarget as ScreenLogicRemoteTarget).password) assertFalse(loaded.toString().contains("secret-pw")) - assertFalse(loaded.toString().contains("AB-CD-EF")) } @Test fun blankPassword_succeeds_recordsNoPasswordRequired() = runBlocking { - val draft = SavedPoolDraftFactory.fromRemote( - systemCode = "AB-CD-EF", - password = "", - displayName = "Home", - controllerIdentity = null, - savePassword = false - ) - val summary = repo.saveAfterSuccessfulConnection(draft) - assertFalse(summary.passwordRequired) - assertTrue(summary.readyToConnect) - val loaded = repo.loadConnection(summary.id) - assertTrue(loaded is SavedPoolConnectionResult.Ready) + val r = repo.saveAfterSuccessfulConnection(draft("AB-CD-EF", "", false)) + as SaveProfileResult.Saved + assertFalse(r.summary.passwordRequired) + assertTrue(repo.loadConnection(r.summary.id) is SavedPoolConnectionResult.Ready) } @Test fun rename_preservesEncryptedRoute() = runBlocking { - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "CODE01", "pw", "Old", null, savePassword = true - ) - ) - repo.rename(summary.id, "New Name") - val list = repo.list() - assertEquals("New Name", list[0].displayName) - val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + val r = repo.saveAfterSuccessfulConnection(draft("CODE01", "pw", true)) + as SaveProfileResult.Saved + repo.rename(r.summary.id, "New Name") + assertEquals("New Name", repo.list()[0].displayName) + val ready = repo.loadConnection(r.summary.id) as SavedPoolConnectionResult.Ready assertEquals("pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) } @Test fun remove_deletesOnlyOneProfile() = runBlocking { - val a = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("A1", "", "A", null, false) - ) - val b = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("B2", "", "B", null, false) - ) - repo.delete(a.id) - val remaining = repo.list() - assertEquals(1, remaining.size) - assertEquals(b.id, remaining[0].id) + val a = repo.saveAfterSuccessfulConnection(draft("A1", "", false, name = "A")) + as SaveProfileResult.Saved + val b = repo.saveAfterSuccessfulConnection(draft("B2", "", false, name = "B")) + as SaveProfileResult.Saved + repo.delete(a.summary.id) + assertEquals(1, repo.list().size) + assertEquals(b.summary.id, repo.list()[0].id) } @Test fun sameControllerIdentity_mergesLocalAndRemote() = runBlocking { val identity = "pentair.screenlogic:controller:42" val remote = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("CODE", "pw", "Home", identity, true) - ) + draft("CODE", "pw", true, identity = identity) + ) as SaveProfileResult.Saved val local = repo.saveAfterSuccessfulConnection( SavedPoolDraftFactory.fromLocal( - gateway = DiscoveredGateway("g", "GW", "10.0.0.1", 80), - password = "", - controllerIdentity = identity, - savePassword = false, + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + identity, + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE, displayName = "Home LAN" ) - ) - assertEquals(remote.id, local.id) + ) as SaveProfileResult.Saved + assertEquals(remote.summary.id, local.summary.id) assertEquals(1, repo.list().size) - val summary = repo.list()[0] - assertTrue(summary.hasLocalRoute) - assertTrue(summary.hasRemoteRoute) + assertTrue(repo.list()[0].hasLocalRoute) + assertTrue(repo.list()[0].hasRemoteRoute) } @Test fun differentControllerIdentities_doNotMerge() = runBlocking { - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C1", "", "One", "id-1", false) - ) - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C2", "", "Two", "id-2", false) - ) + repo.saveAfterSuccessfulConnection(draft("C1", "", false, identity = "id-1")) + repo.saveAfterSuccessfulConnection(draft("C2", "", false, identity = "id-2")) assertEquals(2, repo.list().size) } @Test - fun missingIdentity_remainsSeparate() = runBlocking { - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C1", "", "One", null, false) - ) - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C2", "", "Two", null, false) - ) + fun missingIdentity_remainsSeparate_unlessExactRoute() = runBlocking { + repo.saveAfterSuccessfulConnection(draft("C1", "", false)) + repo.saveAfterSuccessfulConnection(draft("C2", "", false)) assertEquals(2, repo.list().size) } @Test fun lastSuccess_ordering_deterministic() = runBlocking { val older = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C1", "", "Old", null, false, timestampMs = 100) - ) + draft("C1", "", false, name = "Old", ts = 100) + ) as SaveProfileResult.Saved val newer = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C2", "", "New", null, false, timestampMs = 200) - ) + draft("C2", "", false, name = "New", ts = 200) + ) as SaveProfileResult.Saved val list = repo.list() - assertEquals(newer.id, list[0].id) - assertEquals(older.id, list[1].id) + assertEquals(newer.summary.id, list[0].id) + assertEquals(older.summary.id, list[1].id) } @Test - fun maxProfileCount_dropsOldest() = runBlocking { - repeat(6) { i -> + fun maxProfileCount_rejectsNew() = runBlocking { + repeat(5) { i -> repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "C$i", "", "P$i", "id-$i", false, timestampMs = i.toLong() - ) + draft("C$i", "", false, name = "P$i", identity = "id-$i", ts = i.toLong()) ) } - val list = repo.list() - assertEquals(5, list.size) - assertFalse(list.any { it.displayName == "P0" }) + val r = repo.saveAfterSuccessfulConnection( + draft("CX", "", false, name = "Extra", identity = "id-x", ts = 99) + ) + assertTrue(r is SaveProfileResult.ProfileLimitReached) + assertEquals(5, repo.list().size) } @Test @@ -211,14 +157,11 @@ class SavedPoolRepositoryTest { val jobs = (0 until 10).map { i -> async { repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "CODE$i", "", "P$i", "ctrl-$i", false, timestampMs = i.toLong() - ) + draft("CODE$i", "", false, name = "P$i", identity = "ctrl-$i", ts = i.toLong()) ) } } jobs.awaitAll() - // maxProfiles=5 so at most 5 remain, vault still readable val list = repo.list() assertTrue(list.size in 1..5) assertEquals(VaultHealth.AVAILABLE, repo.health()) @@ -226,11 +169,8 @@ class SavedPoolRepositoryTest { @Test fun failedDecrypt_doesNotOverwriteVault() = runBlocking { - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("KEEP", "pw", "Keep", null, true) - ) + repo.saveAfterSuccessfulConnection(draft("KEEP", "pw", true)) val goodEnvelope = store.read()!! - // Install a broken cipher that always fails decrypt val broken = object : VaultCipher { override fun encrypt(plaintext: ByteArray): ByteArray = cipher.encrypt(plaintext) override fun decrypt(envelope: ByteArray): ByteArray { @@ -240,123 +180,85 @@ class SavedPoolRepositoryTest { val brokenRepo = DefaultSavedPoolRepository(broken, store, prefs) try { brokenRepo.list() - fail("expected vault exception") + fail("expected") } catch (e: VaultException) { assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health) } - // Prior vault bytes untouched - assertArrayEquals(goodEnvelope, store.read()!!) + assertTrue(goodEnvelope.contentEquals(store.read()!!)) } @Test - fun delete_clearsControllerTempUnitWhenIdentityKnown() = runBlocking { + fun delete_clearsControllerTempUnitWhenIdentityKnownAndUnshared() = runBlocking { val identity = "pentair.screenlogic:controller:99" ControllerTempUnitStore.save(prefs, identity, TemperatureUnit.CELSIUS) - val summary = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C", "", "X", identity, false) - ) + val r = repo.saveAfterSuccessfulConnection( + draft("C", "", false, identity = identity) + ) as SaveProfileResult.Saved assertNotNull(ControllerTempUnitStore.load(prefs, identity)) - repo.delete(summary.id) + repo.delete(r.summary.id) assertNull(ControllerTempUnitStore.load(prefs, identity)) } @Test fun resetVault_clearsProfilesAndKeyCallback() = runBlocking { var keyReset = false - val r = DefaultSavedPoolRepository(cipher, store, prefs, onKeyReset = { keyReset = true }) - r.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C", "", "X", null, false) + val r = DefaultSavedPoolRepository( + cipher, store, prefs, + onKeyReset = { + keyReset = true + KeyDeleteResult.Deleted + } ) - r.resetVault() + r.saveAfterSuccessfulConnection(draft("C", "", false)) + assertTrue(r.resetVault() is VaultResetResult.Success) assertTrue(keyReset) assertTrue(r.list().isEmpty()) assertFalse(store.exists()) } - @Test - fun updatePasswordAfterSuccess() = runBlocking { - val s = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C", "old", "X", null, savePassword = false) - ) - assertTrue(s.passwordRequired) - repo.updatePasswordAfterSuccess( - s.id, SavedRouteKind.SCREENLOGIC_REMOTE, "new-pw", rememberPassword = true - ) - val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready - assertEquals("new-pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) - } - @Test fun localRoute_loadConnection_buildsTarget() = runBlocking { - val s = repo.saveAfterSuccessfulConnection( + val r = repo.saveAfterSuccessfulConnection( SavedPoolDraftFactory.fromLocal( DiscoveredGateway("g", "Label", "192.168.1.20", 80), - password = "", - controllerIdentity = null, - savePassword = false + "", + null, + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE ) - ) - val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready + ) as SaveProfileResult.Saved + val ready = repo.loadConnection(r.summary.id) as SavedPoolConnectionResult.Ready val local = ready.connectionTarget as ScreenLogicLocalTarget assertEquals("192.168.1.20", local.gateway.address) - assertEquals(80, local.gateway.port) } @Test fun summary_containsNoSecrets() = runBlocking { - val s = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "TOP-SECRET", "hunter2", "My Pool", null, true - ) - ) - val text = s.toString() - assertFalse(text.contains("TOP-SECRET")) - assertFalse(text.contains("hunter2")) - assertTrue(text.contains("My Pool")) + val r = repo.saveAfterSuccessfulConnection( + draft("TOP-SECRET", "hunter2", true, name = "My Pool") + ) as SaveProfileResult.Saved + assertFalse(r.summary.toString().contains("TOP-SECRET")) + assertFalse(r.summary.toString().contains("hunter2")) } @Test fun ordinaryPreferences_containNoSecretsAfterSave() = runBlocking { - repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote( - "SYS-CODE", "the-password", "Pool", null, true - ) - ) + repo.saveAfterSuccessfulConnection(draft("SYS-CODE", "the-password", true)) val snap = prefs.snapshot() assertTrue(snap.isEmpty() || snap.values.none { it.contains("SYS-CODE") || it.contains("the-password") }) - // Vault is not in preferences - assertNull(prefs.getString("saved_pools")) - assertNull(prefs.getString("westcurrent.last_system_code")) } - @Test - fun updateLocalAddress_identityMismatch_refuses() = runBlocking { - val s = repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromLocal( - DiscoveredGateway("g", "L", "10.0.0.1", 80), - "", - controllerIdentity = "ctrl-A", - savePassword = false - ) - ) - repo.updateLocalAddress(s.id, "10.0.0.2", 80, "Other", controllerIdentity = "ctrl-B") - val ready = repo.loadConnection(s.id) as SavedPoolConnectionResult.Ready - val local = ready.connectionTarget as ScreenLogicLocalTarget - // Address must remain original - assertEquals("10.0.0.1", local.gateway.address) - } - - @Test - fun vaultException_toString_hasNoSecrets() { - val ex = VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed") - assertFalse(ex.toString().contains("password")) - assertTrue(ex.toString().contains("AUTH_TAG_FAILURE")) - } - - private fun assertArrayEquals(a: ByteArray, b: ByteArray) { - assertEquals(a.size, b.size) - for (i in a.indices) assertEquals(a[i], b[i]) - } + private fun draft( + code: String, + password: String, + remember: Boolean, + name: String = "Home", + identity: String? = null, + ts: Long = System.currentTimeMillis() + ) = SavedPoolDraftFactory.fromRemote( + code, password, name, identity, + SavedPoolDraftFactory.decisionForNewProfile(remember, password.isEmpty()), + ts + ) } diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt index 8bd057c..de23c6d 100644 --- a/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt @@ -2,16 +2,13 @@ package com.pentaircontrol.pool.savedpools import com.pentaircontrol.pool.domain.DiscoveredGateway import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.io.File import javax.crypto.KeyGenerator -import kotlinx.coroutines.runBlocking -/** - * Security surface tests: ordinary prefs, redaction, vault path, backup XML exclusions. - */ class SavedPoolSecurityAndBackupTest { @Test @@ -19,94 +16,62 @@ class SavedPoolSecurityAndBackupTest { val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() val prefs = InMemoryAppPreferencesStore() val repo = DefaultSavedPoolRepository( - SecretKeyVaultCipher(key), - InMemoryVaultFileStore(), - prefs + SecretKeyVaultCipher(key), InMemoryVaultFileStore(), prefs ) repo.saveAfterSuccessfulConnection( SavedPoolDraftFactory.fromRemote( - systemCode = "SYS-CODE-99", - password = "password-value", - displayName = "Pool", - controllerIdentity = null, - savePassword = true + "SYS-CODE-99", "password-value", "Pool", null, + PasswordSaveDecision.STORE_NEW ) ) repo.saveAfterSuccessfulConnection( SavedPoolDraftFactory.fromLocal( - gateway = DiscoveredGateway("g", "GW", "192.168.44.55", 8080), - password = "local-pass", - controllerIdentity = null, - savePassword = true + DiscoveredGateway("g", "GW", "192.168.44.55", 8080), + "local-pass", null, PasswordSaveDecision.STORE_NEW ) ) - val snap = prefs.snapshot() - val joined = snap.entries.joinToString { "${it.key}=${it.value}" } + val joined = prefs.snapshot().entries.joinToString { "${it.key}=${it.value}" } assertFalse(joined.contains("SYS-CODE-99")) assertFalse(joined.contains("password-value")) assertFalse(joined.contains("192.168.44.55")) assertFalse(joined.contains("local-pass")) - // No digests of secrets should be stored either as convenience history keys - assertFalse(snap.keys.any { it.contains("system_code", ignoreCase = true) }) - assertFalse(snap.keys.any { it.contains("password", ignoreCase = true) }) } @Test fun uiSummaries_containNoSecrets() = runBlocking { val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() - val repo = DefaultSavedPoolRepository( - SecretKeyVaultCipher(key), - InMemoryVaultFileStore() - ) + val repo = DefaultSavedPoolRepository(SecretKeyVaultCipher(key), InMemoryVaultFileStore()) val s = repo.saveAfterSuccessfulConnection( SavedPoolDraftFactory.fromRemote( - "SECRETCODE", "hunter2", "Friendly Name", null, true + "SECRETCODE", "hunter2", "Friendly Name", null, + PasswordSaveDecision.STORE_NEW ) - ) - assertFalse(s.toString().contains("SECRETCODE")) - assertFalse(s.toString().contains("hunter2")) - assertTrue(s.displayName == "Friendly Name") - assertFalse(s.displayName.contains("SECRET")) + ) as SaveProfileResult.Saved + assertFalse(s.summary.toString().contains("SECRETCODE")) + assertFalse(s.summary.toString().contains("hunter2")) } @Test fun secureFile_isUnderNoBackupFilesDir_pattern() { val noBackup = File(System.getProperty("java.io.tmpdir"), "wc-no-backup-test") noBackup.mkdirs() - val store = AtomicVaultFileStore(noBackup) - store.writeAtomic(byteArrayOf(1, 2, 3, 4)) + val store = SimulatedAtomicVaultFileStore(noBackup) + store.writeAtomic(byteArrayOf(1, 2, 3, 4, 5)) assertTrue(store.exists()) - assertTrue(store.filePathForDiagnostics().contains("saved_pools_v1.enc")) - assertTrue(store.directoryForDiagnostics().contains("wc-no-backup-test")) + assertTrue(store.baseFile().name.contains("saved_pools_v1.enc")) store.delete() noBackup.deleteRecursively() } - @Test - fun atomicWrite_replacesPriorContent() { - val dir = File(System.getProperty("java.io.tmpdir"), "wc-atomic-test-${System.nanoTime()}") - dir.mkdirs() - val store = AtomicVaultFileStore(dir) - store.writeAtomic(byteArrayOf(9, 9, 9)) - store.writeAtomic(byteArrayOf(1, 2, 3)) - val read = store.read()!! - assertTrue(read.contentEquals(byteArrayOf(1, 2, 3))) - store.delete() - dir.deleteRecursively() - } - @Test fun backupRulesXml_excludesVaultFile() { val backup = File("src/main/res/xml/backup_rules.xml").readText() assertTrue(backup.contains("saved_pools_v1.enc")) assertTrue(backup.contains(" it.name to line } } + .flatMap { f -> f.readText().lineSequence().map { line -> f.name to line } } .filter { (_, line) -> line.contains("EncryptedSharedPreferences") || line.contains("MasterKey") || line.contains("androidx.security.crypto") } .toList() - assertTrue( - "Deprecated encrypted prefs must not be used: $hits", - hits.isEmpty() - ) + assertTrue("Deprecated encrypted prefs must not be used: $hits", hits.isEmpty()) } @Test fun keystoreAlias_isVersioned() { assertTrue(AndroidKeystoreVaultCipher.KEY_ALIAS.contains("v1")) - assertTrue(AndroidKeystoreVaultCipher.KEY_ALIAS.startsWith("westcurrent.saved_pools")) } @Test @@ -149,42 +109,33 @@ class SavedPoolSecurityAndBackupTest { var keyDeleted = false val store = InMemoryVaultFileStore() val repo = DefaultSavedPoolRepository( - SecretKeyVaultCipher(key), - store, - prefs, - onKeyReset = { keyDeleted = true } + SecretKeyVaultCipher(key), store, prefs, + onKeyReset = { + keyDeleted = true + KeyDeleteResult.Deleted + } ) repo.saveAfterSuccessfulConnection( - SavedPoolDraftFactory.fromRemote("C", "p", "N", null, true) + SavedPoolDraftFactory.fromRemote( + "C", "p", "N", null, PasswordSaveDecision.STORE_NEW + ) ) - repo.resetVault() + assertTrue(repo.resetVault() is VaultResetResult.Success) assertTrue(keyDeleted) assertFalse(store.exists()) - assertTrue(repo.list().isEmpty()) - // Unrelated prefs retained - assertTrue(prefs.getString("westcurrent.appearance") == "dark") + assertEquals("dark", prefs.getString("westcurrent.appearance")) } @Test fun draftAndProfile_toString_redacted() { val draft = SavedPoolDraftFactory.fromRemote( - "CODECODE", "passpass", "Name", null, true + "CODECODE", "passpass", "Name", null, PasswordSaveDecision.STORE_NEW ) assertFalse(draft.toString().contains("CODECODE")) assertFalse(draft.toString().contains("passpass")) - val profile = SavedPoolProfile( - id = SavedPoolId("x"), - displayName = "N", - adapterId = "a", - controllerIdentity = "id", - createdAtMs = 1, - lastSuccessAtMs = 1, - preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, - routes = mutableListOf( - SavedRoute.ScreenLogicRemote("CODECODE", "passpass", false, false) - ) - ) - assertFalse(profile.toString().contains("CODECODE")) - assertFalse(profile.toString().contains("passpass")) + } + + private fun assertEquals(expected: String?, actual: String?) { + org.junit.Assert.assertEquals(expected, actual) } } diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md index d26bf1a..ceecf3f 100644 --- a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -1,7 +1,8 @@ -# Phase 1.6 — Saved Pools and Secure Reconnect +# Phase 1.6 / 1.6.1 — Saved Pools and Secure Reconnect **Product:** WestCurrent by 8 West Ventures, LLC -**Scope:** Securely save successfully connected pool systems and reconnect later without re-entering the controller address or ScreenLogic system code every time. +**Scope:** Securely save successfully connected pool systems and reconnect later. +**1.6.1:** Vault lifetime, AtomicFile, connection-attempt ownership, pre-install identity, password lifecycle, provisional upgrade, capacity, preferred flag, local DHCP recovery, Keystore health. **Not in scope:** Pool-equipment features, auto-connect on launch, biometric unlock, cloud sync, Fable UI polish. Fable did **not** implement security or architecture for this sprint. @@ -18,37 +19,39 @@ Fable did **not** implement security or architecture for this sprint. | Local / remote access password | Secret | | Saved local controller address and port | Sensitive connection material | | Complete serialized connection target / route | Secret | -| Any deterministic digest of system code or password | Forbidden | -| User-facing pool name, last-used time, route label | Non-secret (still stored in vault) | +| Any deterministic digest of system code or password | Forbidden outside vault | +| User-facing pool name, last-used time, route label, preferred flag | Non-secret (still stored in vault) | | Android Keystore AES key | Key material (never exported) | -### Adversaries considered +### Absolute prohibitions -- Casual inspection of ordinary SharedPreferences / DataStore -- Android cloud backup and device-to-device transfer -- Logcat, crash reports, exception messages, `toString()` dumps -- Screenshots of UI (no secrets on cards) -- Stale or corrupted vault files after app update or Keystore invalidation +- No ordinary SharedPreferences / unencrypted DataStore for connection history +- No deprecated `EncryptedSharedPreferences` or `MasterKey` +- No invented crypto; AES-GCM via platform Keystore only +- No key derivation from password, system code, controller ID, Android ID, serial, or package name +- No hard-coded keys, IVs, salts, passwords, or system codes +- No AES-GCM IV reuse +- No plaintext fallback when encryption fails +- No silent overwrite of an undecryptable vault +- No long-lived decrypted profile cache in the repository +- No auto-connect on app launch +- No save of a connection that never succeeded -### Explicit non-goals (Phase 1.6) +### Secret lifetime (1.6.1) -- Protecting secrets from a fully compromised running app process -- Biometric / lock-screen binding -- Multi-device sync -- Server-side credential storage +`DefaultSavedPoolRepository` does **not** retain decrypted `SavedPoolProfile` / `SavedRoute` objects between calls. -### Absolute prohibitions (enforced in design) +Every operation: -- No ordinary SharedPreferences / unencrypted DataStore for connection history -- No deprecated `EncryptedSharedPreferences` or `MasterKey` -- No invented crypto; AES-GCM via platform Keystore only -- No key derivation from password, system code, controller ID, Android ID, serial, or package name -- No hard-coded keys, IVs, salts, passwords, or system codes -- No AES-GCM IV reuse -- No plaintext fallback when encryption fails -- No silent overwrite of an undecryptable vault -- No auto-connect on app launch -- No save of a connection that never succeeded +1. Acquires mutex +2. Reads encrypted vault +3. Decrypts locally +4. Decodes locally +5. Performs the operation (copy-on-write for mutations) +6. Re-encrypts when necessary +7. Zeros temporary plaintext `ByteArray`s in `finally` + +Immutable JVM `String` objects cannot be reliably zeroed; their scope is minimized instead. --- @@ -57,14 +60,13 @@ Fable did **not** implement security or architecture for this sprint. | Layer | Responsibility | | --- | --- | | `SavedPoolSummary` / UI state | Non-secret only | -| `SavedPoolRepository` | Merge, consent, load/decrypt for connect | -| `SavedPoolCodec` | Pure Kotlin versioned serialization of decrypted payload | -| `VaultCipher` / `AesGcmVaultEnvelope` | AES-GCM envelope with AAD | -| `AndroidKeystoreVaultCipher` | Keystore-backed AES-256 key | -| `AtomicVaultFileStore` | Atomic write under `noBackupFilesDir` | -| `SiteSession` | Existing connection engine (generation, cancel, reconnect) | - -Decrypted connection material exists only long enough to build an adapter-owned `AdapterConnectionTarget` and begin `SiteSession` connect. It is **never** placed in `StateFlow`, Compose state, diagnostics models, or public summaries. +| `SavedPoolRepository` | Merge, consent, load/decrypt for connect — no secret cache | +| `SavedPoolCodec` | Versioned binary payload (`WCSV` v1) | +| `VaultCipher` / `AesGcmVaultEnvelope` | AES-GCM envelope (`WCAE` v1 + AAD) | +| `AndroidKeystoreVaultCipher` | Keystore-backed AES-256-GCM | +| `AndroidAtomicVaultFileStore` | Platform `AtomicFile` under `noBackupFilesDir` | +| `SiteSession` + `ConnectionAttemptResult` | Only connection engine; typed attempt ownership | +| `PoolViewModel` request epoch | Invalidates stale decrypt/connect before SiteSession | --- @@ -74,123 +76,110 @@ Decrypted connection material exists only long enough to build an adapter-owned | --- | --- | | Provider | `AndroidKeyStore` | | Alias | `westcurrent.saved_pools.aes.v1` | -| Algorithm | AES | -| Key size | 256 bits | -| Purposes | Encrypt, Decrypt | -| Block mode | GCM | -| Padding | None | +| Algorithm / size | AES / 256 | +| Mode / padding | GCM / none | | Randomized encryption | Required | -| User authentication | **Not** required (Phase 1.6) | -| StrongBox | Preferred when available; **not** required | +| User authentication | Not required | +| StrongBox | Not blindly attempted; security level from `KeyInfo` when available | + +### Health mapping + +| Condition | VaultHealth | +| --- | --- | +| Missing alias | `KEYSTORE_MISSING` | +| Permanently invalidated / Cipher.init failure | `KEY_INVALIDATED` | +| Provider temporarily unavailable | `LOCKED_OR_UNAVAILABLE` | +| AES-GCM tag mismatch | `AUTH_TAG_FAILURE` | +| Envelope/schema errors | `CORRUPT` / `UNSUPPORTED_SCHEMA` | +| Disk failure | `IO_FAILURE` | + +`Cipher.init()` failures are **not** mapped to auth-tag failure. -**Important:** Android Keystore protects the encryption key from extraction; it does **not** protect a compromised running app that can already call decrypt APIs. +### Key deletion / reset -Keystore and disk I/O run on `Dispatchers.IO`, not the main thread. +`deleteKey()` returns `KeyDeleteResult` and verifies alias absence. +`resetVault()` succeeds only when vault files **and** Keystore alias are removed. Partial failure is reported, not success. --- ## 4. Cipher-envelope format -Magic: `WCAE` (ASCII) -Version: `1` (u8) -Layout: - ``` -magic[4] = "WCAE" -version u8 = 1 -ivLen u8 = 12 -iv[12] -ciphertext || GCM tag (128-bit tag) +"WCAE" | version=1 | ivLen=12 | iv[12] | ciphertext||tag(128) +AAD: "WestCurrentSavedPools:v1" ``` -**AAD (associated authenticated data):** UTF-8 string -`WestCurrentSavedPools:v1` - -IV is random per write via `SecureRandom`. Never reused with the same key by the writer. +Random IV per write; never reused by the writer. Max envelope 600_000 bytes (checked before full allocation on read). --- ## 5. Secure storage path -``` -context.noBackupFilesDir / saved_pools_v1.enc -``` +`context.noBackupFilesDir / saved_pools_v1.enc` -Atomic write pattern: +### AtomicFile (1.6.1) -1. Encrypt complete new vault payload -2. Write `saved_pools_v1.enc.tmp` -3. Flush / best-effort `fsync` -4. Rename over prior file (with backup rename fallback) +Production: `android.util.AtomicFile` -A crash leaves either the prior valid vault or the new valid vault. +- `startWrite()` → write complete envelope → `finishWrite()` +- `failWrite()` on any failure +- No `File.writeBytes()` fallback over the live vault ---- +JVM tests: `SimulatedAtomicVaultFileStore` (same base/`.new` protocol) + `InMemoryVaultFileStore`. + +Zero-byte existing vault → `CORRUPT`. Absent vault → `EMPTY`. -## 6. Backup and device-transfer exclusions +--- -- `android:allowBackup="true"` remains for non-sensitive preferences -- `fullBackupContent` → `@xml/backup_rules` excludes vault filenames -- `dataExtractionRules` → `@xml/data_extraction_rules` excludes vault from **cloud-backup** and **device-transfer** -- Vault lives under `noBackupFilesDir` (platform excludes from legacy full backup) +## 6. Backup exclusions -The secure vault must never leave the device through Android backup. +- `allowBackup=true` for non-sensitive prefs +- `backup_rules.xml` / `data_extraction_rules.xml` exclude vault +- Vault under `noBackupFilesDir` --- ## 7. Saved-profile schema (payload v1) -Decrypted payload magic: `WCSV`, schema version `1`. - -Per profile: +Magic `WCSV`, schema version `1`. -- Opaque random profile ID (`UUID`) -- Display name -- Adapter ID -- Optional controller-reported identity -- Created / last-success timestamps -- Preferred route kind -- One or more routes +Per profile: opaque UUID, display name, adapter ID, optional controller identity, created/last-success, preferred route, **isPreferred boolean**, routes. ### Routes -**ScreenLogic remote (`sl_remote`)** +**Remote:** system code, optional password, passwordRequired, blankPasswordSucceeded +**Local:** address, port, gateway label, optional password, flags, optional controller identity -- Full system code -- Optional password -- `passwordRequired` -- `blankPasswordSucceeded` +### Sorting -**ScreenLogic local (`sl_local`)** +1. Preferred profile +2. Last successful connection time +3. Stable id tie-breaker -- Address, port, gateway label -- Optional password -- `passwordRequired` / `blankPasswordSucceeded` -- Optional controller identity on the route +Only a successful connection updates `lastSuccessAtMs`. Prefer does **not**. -Vendor fields stay outside shared domain models; routes are vendor-owned in the vault package. +### Capacity + +Default max 40 profiles. New profiles at limit → `ProfileLimitReached` (no silent drop). Merge/update of existing profiles still works. --- -## 8. Local and remote route designs +## 8. Local and remote routes ### Remote reconnect -1. Decrypt system code only when connecting -2. Resolve through existing ScreenLogic remote flow (`SiteSession.connectWithFactory`) -3. Use saved password only if user opted in -4. Otherwise prompt **only** for password -5. Auth failure keeps the profile -6. Optional password update only after a successful authenticated connection +Decrypt system code only when connecting; existing ScreenLogic remote flow; password only if remembered; else password-only prompt; auth failure keeps profile; password update only after success. -### Local reconnect +### Local reconnect + DHCP recovery (1.6.1) 1. Try last-known address once -2. On success, verify controller identity when both expected and actual are known -3. Identity conflict → disconnect candidate; do not install -4. Address failure → user may use existing **Find on Wi-Fi** discovery (no background scanner) -5. Successful discovery reconnect with matching identity may update saved local address -6. Missing identity → conservative; do not silently replace routes with unrelated devices +2. Pre-install identity validation when expected identity known +3. On failure: keep profile; offer **Find on Wi‑Fi** (user-initiated discovery only) +4. Present gateways for user selection — no auto-login to every device +5. Connect selected gateway as private candidate +6. Validate identity before install +7. Update encrypted local address only after successful validation +8. Missing identity: do not silently replace route with unrelated device --- @@ -198,108 +187,112 @@ Vendor fields stay outside shared domain models; routes are vendor-owned in the | Control | Default | | --- | --- | -| Save this pool on this phone | **Off** | -| Remember password securely | **Off** (only shown when Save is on and password non-blank) | +| Save this pool on this phone | Off | +| Remember password securely | Off | -- Nothing is saved until the connection **succeeds** -- Failed connects create no profile and do not update secrets -- Blank password that succeeds records “no password required” -- Password-only prompt on reconnect offers Remember again (default off) +### PasswordSaveDecision (1.6.1) -Copy states: encrypted on this phone; password optional; removable later; save/reconnect never sends equipment commands by itself. +| Decision | Meaning | +| --- | --- | +| `OMIT_FOR_NEW_PROFILE` | New profile; do not store password | +| `PRESERVE_EXISTING` | Route update keeps stored password | +| `STORE_NEW` | After success, store provided password | +| `REMOVE_EXISTING` | Explicit forget / remember-off after replacement success | ---- +Remember-off on a route update does **not** silently erase a stored password. Removal is explicit or after successful replacement with remember off. -## 10. Controller-identity merge rules +Stored password auth failure: keep profile and old password; prompt for replacement only; update vault only after successful auth. -- After success, controller-reported identity (when available) may merge remote + local routes into one profile -- DHCP address change must not create a duplicate when identity proves same controller -- Different identities never merge automatically -- Missing / placeholder identity: save allowed; profile stays independent until identity is known -- Identity is **never** an encryption key or a substitute for the system code +Settings shows non-secret status only: Password remembered / required / not needed + Forget password action. --- -## 11. One-tap reconnect lifecycle - -1. User taps Recent pool (no auto-connect on launch) -2. `loadConnection` on IO dispatcher -3. If password required → password dialog only (no system code / address re-entry) -4. Build adapter-owned target -5. `SiteSession.connectWithFactory` — same generation, cancellation, reconnect protections -6. On success: update last-success; optional password remember; optional local address update -7. On failure: keep profile and last-success timestamp; plain-language error; allow retry / rename / remove +## 10. Controller-identity merge + provisional upgrade -No second connection engine. No equipment command is submitted by reconnect itself. +1. Prefer exact adapter + controller identity match +2. If new connection has identity and no identity match: upgrade exact-route provisional profile (identity null) +3. Remote provisional match: normalized full system code +4. Local provisional match: address + port +5. Never merge different known identities +6. Never persist digests of system code/address outside the encrypted vault --- -## 12. Vault corruption / invalidation handling +## 11. One-tap reconnect + attempt ownership (1.6.1) + +### ConnectionAttemptResult + +`Installed` | `Failed` | `Superseded` | `Rejected` | `Closed` -Explicit `VaultHealth` outcomes: Available, Empty, Keystore missing, Key invalidated, Auth tag failure, Unsupported schema, Corrupt, IO failure, Locked/unavailable. +Save / last-success / password / active-profile updates only on **Installed** for that exact attempt generation and target. -**Fail-closed:** +### ViewModel request epoch -- Do not regenerate key and overwrite silently -- Do not show partial profiles -- Do not fall back to plaintext -- Message: “Saved pools could not be opened on this device.” -- Offer: Retry · Reset saved pools (confirmed) +Starting request B invalidates A before A can decrypt, connect, install, save, or show a password prompt. Disconnect and ViewModel clear invalidate outstanding work. -Reset removes: vault file, Keystore alias for saved pools, saved-pool preference references tied to removed profiles. Does **not** erase appearance or unrelated settings. +### Pre-install identity validation + +1. Candidate adapter connects privately +2. Candidate snapshot + identity obtained (no global mutation yet) +3. Expected identity compared +4. Only valid candidates enter `adapterRef` / `_site` / `_connection` / polling +5. Mismatch → cleanup disconnect → `Rejected` — no temporary live controls --- -## 13. Logging and redaction +## 12. Vault corruption handling + +Fail-closed; message “Saved pools could not be opened on this device.”; Retry + confirmed Reset (vault file + Keystore alias only; not appearance prefs). -- Secret-bearing types are not naive data classes with default `toString` -- `toString()` redacts system code, password, full address -- Diagnostics may show profile ID, adapter ID, route type, last success, Keystore security level label, vault schema, vault health -- No “show secret” feature +Reset All does **not** erase unrelated controller temperature preferences for controllers still referenced… (profiles are wiped, so identity-linked temp units for removed profiles are not bulk-cleared on reset unless product policy expands later; per-profile delete clears temp unit only when no remaining profile shares the identity). --- -## 14. Tests +## 13. Compose sensitive state (1.6.1) -Categories: +Do **not** use `rememberSaveable` for: -- Serialization (codec round-trip, corrupt, version, allocation bounds) -- Encryption envelope (GCM auth, IV uniqueness, AAD/version, secret absence in ciphertext) -- Repository (consent, merge, remove, concurrent save, fail-closed) -- Connection behavior (SiteSession path, password required, no commands) -- Security / backup XML / no EncryptedSharedPreferences +- System code +- Local address / port +- Passwords -Existing Phase 1.x tests remain green. +Use `remember`; system code masked by default with controlled reveal. Source guard test enforces this. --- -## 15. Physical verification checklist (Frankie) +## 14. Logging and redaction + +Secret-bearing types use redacted `toString()`. Diagnostics: profile id, adapter, route type, last success, Keystore level label, vault health/schema. No “show secret.” -APK: `%TEMP%\westcurrent-app-build\outputs\apk\debug\app-debug.apk` +--- + +## 15. Tests + +Categories: serialization, encryption envelope, repository (no-cache, password, provisional, capacity, prefer), connection behavior, atomic crash recovery, attempt ownership / races, pre-install identity, security/backup, Compose source guard. + +Existing Phase 1.x tests remain green. + +--- -1. **Save without password** — remote connect, Save on, Remember off → disconnect → Recent pools → password-only prompt → connect -2. **Save remembered password** — Remember on → force-stop → one-tap reconnect without code/password -3. **Rename / remove** — rename persists; remove clears after restart -4. **Safety** — no circuit / heat / light commands during reconnect +## 16. Physical verification checklist (Frankie) -Record any accidental live command honestly. +Deferred until 1.6.1 passes review. APK: `%TEMP%\westcurrent-app-build\outputs\apk\debug\app-debug.apk` --- -## 16. Known limitations +## 17. Known limitations -- Android Keystore protects the key, not a compromised running app -- No biometric requirement in Phase 1.6 -- No automatic connection on launch -- Saved profiles are **device-local** and intentionally not backed up -- Uninstalling the app or clearing app data removes saved pools -- Moving to another phone requires setting them up again -- Local DHCP recovery relies on user-driven discovery, not background scanning -- StrongBox is optional -- Simulator connections are not saved as pool profiles +- Keystore protects the key, not a compromised running app +- No biometric requirement +- No auto-connect on launch +- Device-local only; not backed up +- Uninstall / clear data removes saved pools +- Local recovery is user-driven discovery +- JVM Strings not zeroable +- Simulator not saved as pool profile --- -## 17. Architecture summary +## 18. Architecture summary -Small, testable layers: pure Kotlin models + codec, injectable `VaultCipher`, atomic no-backup store, repository with mutex, Android Keystore production cipher, ViewModel orchestration that never stores secrets in UI state, existing `SiteSession` as the only connection engine. +Small, testable layers: pure Kotlin models + codec, injectable cipher, AtomicFile no-backup store, cache-free repository with mutex and COW, typed connection attempts, ViewModel request epoch, existing `SiteSession` as the only connection engine. From dc8bf20d56036ff5369006c9fcf1d826c67e378c Mon Sep 17 00:00:00 2001 From: "8 West Ventures, LLC" Date: Sun, 12 Jul 2026 23:45:50 -0700 Subject: [PATCH 3/6] Phase 1.6.2: Final saved-pool correctness and identity hardening Carry PasswordPromptReason through replace flows so remember-off removes a known-bad stored password after successful override. Use PRESERVE_EXISTING on normal merges and Save current when Remember is off. Persist expected controller identity for the session and enforce it on install, automatic reconnect, and explicit retry. Reject when expected identity is known but the candidate reports none. Fix provisional merge so identity-null connections never merge into known-identity profiles. Own local-recovery discovery with epoch cancel; restrict Find on Wi-Fi to local reachability failures only. Expose route-specific password status and forget actions. Read AtomicFile only via openRead recovery. Complete Keystore exception mapping. Zero codec intermediate UTF-8 buffers. Add Phase 1.6.2 tests and androidTest Keystore suite. Fable did not implement security or architecture. PR remains draft. --- ...roidKeystoreVaultCipherInstrumentedTest.kt | 90 +++ .../com/pentaircontrol/pool/MainActivity.kt | 4 +- .../savedpools/AndroidKeystoreVaultCipher.kt | 59 +- .../pool/savedpools/ConnectSaveOptions.kt | 18 +- .../pool/savedpools/SavedPoolCodec.kt | 107 +-- .../pool/savedpools/SavedPoolModels.kt | 15 +- .../pool/savedpools/SavedPoolRepository.kt | 84 ++- .../pool/savedpools/VaultCipher.kt | 16 +- .../pool/savedpools/VaultFileStore.kt | 79 ++- .../pool/session/ConnectionAttemptResult.kt | 16 +- .../pool/session/SiteSession.kt | 135 +++- .../java/com/pentaircontrol/pool/ui/AppNav.kt | 4 +- .../pentaircontrol/pool/ui/PoolViewModel.kt | 226 +++++-- .../pool/ui/screens/ConnectScreen.kt | 8 +- .../pool/ui/screens/SettingsScreen.kt | 59 +- .../savedpools/Phase162CorrectnessTest.kt | 636 ++++++++++++++++++ .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 22 +- 17 files changed, 1334 insertions(+), 244 deletions(-) create mode 100644 app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt create mode 100644 app/src/test/java/com/pentaircontrol/pool/savedpools/Phase162CorrectnessTest.kt diff --git a/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt b/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt new file mode 100644 index 0000000..ddd5041 --- /dev/null +++ b/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt @@ -0,0 +1,90 @@ +package com.pentaircontrol.pool.savedpools + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Real Android Keystore suite for saved-pools vault cipher. + * Run on emulator/device (Frankie or CI with Android runtime). + * + * Covers: alias create, encrypt/decrypt round trip, deletion, missing alias, + * tampered ciphertext → AUTH_TAG_FAILURE. + */ +@RunWith(AndroidJUnit4::class) +@SmallTest +class AndroidKeystoreVaultCipherInstrumentedTest { + + private val alias = "westcurrent.saved_pools.aes.v1.instrumented" + private lateinit var cipher: AndroidKeystoreVaultCipher + + @Before + fun setUp() { + cipher = AndroidKeystoreVaultCipher(keyAlias = alias) + cipher.deleteKey() + } + + @After + fun tearDown() { + cipher.deleteKey() + } + + @Test + fun create_encrypt_decrypt_roundTrip() { + val plain = "round-trip-payload".toByteArray() + val env = cipher.encrypt(plain) + val out = cipher.decrypt(env) + assertArrayEquals(plain, out) + assertTrue(cipher.keyExists()) + } + + @Test + fun delete_removesAlias() { + cipher.encrypt(byteArrayOf(1, 2, 3)) + val result = cipher.deleteKey() + assertTrue(result is KeyDeleteResult.Deleted || result is KeyDeleteResult.AlreadyAbsent) + assertFalse(cipher.keyExists()) + try { + cipher.decrypt(byteArrayOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20)) + fail("expected missing key") + } catch (e: VaultException) { + assertEquals(VaultHealth.KEYSTORE_MISSING, e.health) + } + } + + @Test + fun missingAlias_decrypt_isKeystoreMissing() { + assertFalse(cipher.keyExists()) + try { + // minimal fake envelope — decrypt fails at key lookup first + cipher.decrypt(ByteArray(40) { 1 }) + fail("expected") + } catch (e: VaultException) { + assertTrue( + e.health == VaultHealth.KEYSTORE_MISSING || + e.health == VaultHealth.CORRUPT || + e.health == VaultHealth.LOCKED_OR_UNAVAILABLE + ) + } + } + + @Test + fun tamperedCiphertext_authTagFailure() { + val env = cipher.encrypt("secret-payload".toByteArray()).copyOf() + env[env.size - 1] = (env[env.size - 1].toInt() xor 0xFF).toByte() + try { + cipher.decrypt(env) + fail("expected auth failure") + } catch (e: VaultException) { + assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health) + } + } +} diff --git a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt index a937e4a..6069848 100644 --- a/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt +++ b/app/src/main/java/com/pentaircontrol/pool/MainActivity.kt @@ -99,8 +99,8 @@ class MainActivity : ComponentActivity() { savedPoolsState = savedPoolsState, passwordPrompt = passwordPrompt, onConnectSavedPool = vm::connectSavedPool, - onSubmitSavedPassword = { id, pass, remember, epoch -> - vm.submitSavedPoolPassword(id, pass, remember, epoch) + onSubmitSavedPassword = { id, pass, remember, epoch, reason -> + vm.submitSavedPoolPassword(id, pass, remember, epoch, reason) }, onDismissPasswordPrompt = vm::dismissPasswordPrompt, onRenameSavedPool = vm::renameSavedPool, diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt index 22ebd50..b5a177d 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt @@ -24,32 +24,25 @@ class AndroidKeystoreVaultCipher( ) : VaultCipher { override fun encrypt(plaintext: ByteArray): ByteArray { - val key = getOrCreateKey() return try { + val key = getOrCreateKey() AesGcmVaultEnvelope.encrypt(key, plaintext) } catch (e: VaultException) { throw e } catch (e: Exception) { - throw mapCryptoFailure(e, encrypting = true) + throw mapKeyOrCryptoFailure(e, encrypting = true) } } override fun decrypt(envelope: ByteArray): ByteArray { - val key = try { - getExistingKey() - } catch (e: UnrecoverableKeyException) { - throw VaultException(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated", e) - } catch (e: ProviderException) { - throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore temporarily unavailable", e) - } catch (e: Exception) { - throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing", e) - } ?: throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing") return try { + val key = getExistingKey() + ?: throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing") AesGcmVaultEnvelope.decrypt(key, envelope) } catch (e: VaultException) { throw e } catch (e: Exception) { - throw mapCryptoFailure(e, encrypting = false) + throw mapKeyOrCryptoFailure(e, encrypting = false) } } @@ -65,19 +58,25 @@ class AndroidKeystoreVaultCipher( return KeyDeleteResult.AlreadyAbsent } ks.deleteEntry(keyAlias) - // Verify absence ks.load(null) if (ks.containsAlias(keyAlias)) { KeyDeleteResult.Failed( - VaultHealth.IO_FAILURE, + VaultHealth.KEYSTORE_MISSING, "Keystore alias still present after delete" ) } else { KeyDeleteResult.Deleted } + } catch (e: UnrecoverableKeyException) { + KeyDeleteResult.Failed(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated") + } catch (e: ProviderException) { + KeyDeleteResult.Failed( + VaultHealth.LOCKED_OR_UNAVAILABLE, + "Keystore temporarily unavailable" + ) } catch (e: Exception) { KeyDeleteResult.Failed( - VaultHealth.IO_FAILURE, + VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore key deletion failed" ) } @@ -156,26 +155,40 @@ class AndroidKeystoreVaultCipher( return entry.secretKey } - private fun mapCryptoFailure(e: Exception, encrypting: Boolean): VaultException { + /** + * Maps key access/creation and crypto failures. Does **not** map every Cipher.init + * failure to KEY_INVALIDATED — only clear invalidation / unrecoverable cases. + * Genuine GCM tag failures from doFinal → AUTH_TAG_FAILURE. + */ + private fun mapKeyOrCryptoFailure(e: Exception, encrypting: Boolean): VaultException { + // Envelope parser already throws typed VaultException for schema/magic. + if (e is VaultException) return e val msg = e.message.orEmpty() return when { e is UnrecoverableKeyException || - msg.contains("Key permanently invalidated", ignoreCase = true) || - msg.contains("KeyUserNotAuthenticated", ignoreCase = true) -> + msg.contains("Key permanently invalidated", ignoreCase = true) -> VaultException(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated", e) e is ProviderException || - msg.contains("KeyStore is not available", ignoreCase = true) -> + msg.contains("KeyStore is not available", ignoreCase = true) || + msg.contains("not initialized", ignoreCase = true) && + msg.contains("KeyStore", ignoreCase = true) -> VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore unavailable", e) !encrypting && ( - msg.contains("MAC", ignoreCase = true) || - msg.contains("tag", ignoreCase = true) || + e is javax.crypto.AEADBadTagException || + msg.contains("MAC", ignoreCase = true) || + msg.contains("tag mismatch", ignoreCase = true) || msg.contains("AEADBadTag", ignoreCase = true) ) -> VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + encrypting && ( + e is java.security.InvalidKeyException || + msg.contains("KeyStoreException", ignoreCase = true) + ) -> + VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key generation failed", e) else -> VaultException( - if (encrypting) VaultHealth.IO_FAILURE else VaultHealth.AUTH_TAG_FAILURE, - if (encrypting) "Vault encryption failed" else "Vault authentication failed", + VaultHealth.LOCKED_OR_UNAVAILABLE, + if (encrypting) "Vault encryption failed" else "Vault decryption failed", e ) } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt index 0a1f598..ade8510 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt @@ -14,6 +14,17 @@ class ConnectSaveOptions( "ConnectSaveOptions(savePool=$savePool, rememberPassword=$rememberPassword)" } +/** + * Why the password prompt is shown. Non-secret; must survive through the successful + * override connection so Remember-off can remove a known-bad stored password. + */ +enum class PasswordPromptReason { + /** Route has no stored password (or blank never succeeded). */ + MISSING_PASSWORD, + /** A remembered password was rejected; user is supplying a replacement. */ + REPLACE_REJECTED_STORED_PASSWORD +} + /** * Non-secret UI state for a password-only reconnect prompt. * Never carries system code, address, or decrypted route material. @@ -22,13 +33,16 @@ class SavedPoolPasswordPrompt( val profileId: SavedPoolId, val displayName: String, val routeKind: SavedRouteKind, - val replacingFailedStoredPassword: Boolean = false, + val reason: PasswordPromptReason = PasswordPromptReason.MISSING_PASSWORD, /** ViewModel request epoch that created this prompt — stale prompts are ignored. */ val requestEpoch: Int = 0 ) { + val isReplacement: Boolean + get() = reason == PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD + override fun toString(): String = "SavedPoolPasswordPrompt(id=$profileId, name=$displayName, kind=$routeKind, " + - "replacing=$replacingFailedStoredPassword, epoch=$requestEpoch)" + "reason=$reason, epoch=$requestEpoch)" } /** diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt index 289adfb..c8306c7 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt @@ -24,47 +24,51 @@ object SavedPoolCodec { require(profiles.size <= MAX_PROFILES) { "Too many profiles" } val ids = profiles.map { it.id.value }.toSet() require(ids.size == profiles.size) { "Duplicate profile ids" } - val baos = ByteArrayOutputStream() - DataOutputStream(baos).use { out -> - out.writeBytes(MAGIC) - out.writeShort(SCHEMA_VERSION) - out.writeInt(profiles.size) - for (p in profiles) { - writeString(out, p.id.value) - writeString(out, p.displayName) - writeString(out, p.adapterId) - writeString(out, p.controllerIdentity ?: "") - out.writeLong(p.createdAtMs) - out.writeLong(p.lastSuccessAtMs) - out.writeUTF(p.preferredRoute.name) - out.writeBoolean(p.isPreferred) - out.writeInt(p.routes.size) - for (r in p.routes) { - when (r) { - is SavedRoute.ScreenLogicRemote -> { - out.writeUTF("sl_remote") - writeString(out, r.systemCode) - writeString(out, r.password ?: "") - out.writeBoolean(r.passwordRequired) - out.writeBoolean(r.blankPasswordSucceeded) - } - is SavedRoute.ScreenLogicLocal -> { - out.writeUTF("sl_local") - writeString(out, r.address) - out.writeInt(r.port) - writeString(out, r.gatewayLabel) - writeString(out, r.password ?: "") - out.writeBoolean(r.passwordRequired) - out.writeBoolean(r.blankPasswordSucceeded) - writeString(out, r.controllerIdentity ?: "") + val baos = ZeroingByteArrayOutputStream() + try { + DataOutputStream(baos).use { out -> + out.writeBytes(MAGIC) + out.writeShort(SCHEMA_VERSION) + out.writeInt(profiles.size) + for (p in profiles) { + writeString(out, p.id.value) + writeString(out, p.displayName) + writeString(out, p.adapterId) + writeString(out, p.controllerIdentity ?: "") + out.writeLong(p.createdAtMs) + out.writeLong(p.lastSuccessAtMs) + out.writeUTF(p.preferredRoute.name) + out.writeBoolean(p.isPreferred) + out.writeInt(p.routes.size) + for (r in p.routes) { + when (r) { + is SavedRoute.ScreenLogicRemote -> { + out.writeUTF("sl_remote") + writeString(out, r.systemCode) + writeString(out, r.password ?: "") + out.writeBoolean(r.passwordRequired) + out.writeBoolean(r.blankPasswordSucceeded) + } + is SavedRoute.ScreenLogicLocal -> { + out.writeUTF("sl_local") + writeString(out, r.address) + out.writeInt(r.port) + writeString(out, r.gatewayLabel) + writeString(out, r.password ?: "") + out.writeBoolean(r.passwordRequired) + out.writeBoolean(r.blankPasswordSucceeded) + writeString(out, r.controllerIdentity ?: "") + } } } } } + val bytes = baos.toByteArray() + require(bytes.size <= MAX_PAYLOAD) { "Payload too large" } + return bytes + } finally { + baos.zero() } - val bytes = baos.toByteArray() - require(bytes.size <= MAX_PAYLOAD) { "Payload too large" } - return bytes } fun decode(bytes: ByteArray): List { @@ -175,9 +179,14 @@ object SavedPoolCodec { private fun writeString(out: DataOutputStream, value: String) { val raw = value.toByteArray(StandardCharsets.UTF_8) - require(raw.size <= MAX_STRING) { "String too long" } - out.writeInt(raw.size) - out.write(raw) + try { + require(raw.size <= MAX_STRING) { "String too long" } + out.writeInt(raw.size) + out.write(raw) + } finally { + // Best-effort: system codes / passwords / addresses may be in this buffer. + raw.fill(0) + } } private fun readString(inp: DataInputStream): String { @@ -186,7 +195,23 @@ object SavedPoolCodec { throw VaultException(VaultHealth.CORRUPT, "Invalid string length") } val raw = ByteArray(len) - inp.readFully(raw) - return String(raw, StandardCharsets.UTF_8) + try { + inp.readFully(raw) + return String(raw, StandardCharsets.UTF_8) + } finally { + // Best-effort zero of UTF-8 intermediate. Immutable JVM String cannot be zeroed. + raw.fill(0) + } + } + + /** + * [ByteArrayOutputStream] whose internal buffer can be wiped after [toByteArray]. + * Does not make immutable Strings zeroable — only intermediate byte buffers. + */ + class ZeroingByteArrayOutputStream : ByteArrayOutputStream() { + fun zero() { + buf.fill(0) + count = 0 + } } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt index f0fb1a9..3c11c65 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt @@ -27,15 +27,22 @@ class SavedPoolSummary( val hasRemoteRoute: Boolean, /** Explicit user-preferred recent entry — not derived from last-success time. */ val isPreferred: Boolean, + /** Preferred route for reconnect; non-secret. */ + val preferredRouteKind: SavedRouteKind, /** - * Non-secret password status for Settings: - * REMEMBERED / REQUIRED / NOT_NEEDED + * Preferred-route password status (legacy convenience for Recent Pools). + * Settings should use [remotePasswordStatus] / [localPasswordStatus]. */ - val passwordStatus: PasswordStatus + val passwordStatus: PasswordStatus, + /** Non-secret remote route password status, or null if no remote route. */ + val remotePasswordStatus: PasswordStatus?, + /** Non-secret local route password status, or null if no local route. */ + val localPasswordStatus: PasswordStatus? ) { override fun toString(): String = "SavedPoolSummary(id=$id, name=$displayName, route=$routeKindLabel, " + - "passwordStatus=$passwordStatus, ready=$readyToConnect, preferred=$isPreferred)" + "passwordStatus=$passwordStatus, remote=$remotePasswordStatus, " + + "local=$localPasswordStatus, ready=$readyToConnect, preferred=$isPreferred)" } enum class PasswordStatus { diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt index 94e8ac1..4b71b4c 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt @@ -384,9 +384,8 @@ class DefaultSavedPoolRepository( override suspend fun resetVault(): VaultResetResult = mutex.withLock { try { - if (store.exists()) { - store.delete() - } + // delete is idempotent when absent; avoid exists() bypassing AtomicFile recovery + store.delete() } catch (e: VaultException) { lastHealth = e.health return VaultResetResult.PartialFailure(e.health, "Vault file could not be removed") @@ -421,15 +420,17 @@ class DefaultSavedPoolRepository( * JVM String fields cannot be zeroed; minimize scope. */ private inline fun withProfiles(block: (List) -> T): T { - if (!store.exists()) { + // Do not probe exists() separately — AtomicFile recovery is owned by read(). + val envelope = try { + store.read() + } catch (e: VaultException) { + lastHealth = e.health + throw e + } + if (envelope == null) { lastHealth = VaultHealth.EMPTY return block(emptyList()) } - val envelope = store.read() - ?: run { - lastHealth = VaultHealth.EMPTY - return block(emptyList()) - } var plain: ByteArray? = null return try { plain = cipher.decrypt(envelope) @@ -444,6 +445,7 @@ class DefaultSavedPoolRepository( throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Decrypt failed", e) } finally { plain?.fill(0) + envelope.fill(0) } } @@ -459,32 +461,41 @@ class DefaultSavedPoolRepository( } } + /** + * Merge precedence (1.6.2): + * 1. Exact adapter + known controller identity + * 2. New known identity upgrading exact-route profile with stored identity null + * 3. Identity-null connection matching exact-route profile with stored identity null + * 4. Otherwise separate profile + * + * Never merges into a profile that already has a different known identity. + * Never merges an identity-null connection into a profile that already has a known identity. + */ private fun findMergeIndex( profiles: List, draft: SavedPoolDraft, identity: String? ): Int { - // 1. Prefer exact adapter + controller identity match if (identity != null) { val byId = profiles.indexOfFirst { it.controllerIdentity == identity && it.adapterId == draft.adapterId } if (byId >= 0) return byId - // 2. Upgrade provisional exact-route match (identity null) + // Upgrade provisional exact-route profile (identity still null) val provisional = profiles.indexOfFirst { p -> p.adapterId == draft.adapterId && p.controllerIdentity == null && exactRouteMatch(p, draft.route) } if (provisional >= 0) return provisional - } else { - // No identity on new connection: exact route match only for provisional merge - val exact = profiles.indexOfFirst { p -> - p.adapterId == draft.adapterId && exactRouteMatch(p, draft.route) - } - if (exact >= 0) return exact + return -1 + } + // Identity-null connection: only merge with identity-null exact-route profile + return profiles.indexOfFirst { p -> + p.adapterId == draft.adapterId && + p.controllerIdentity == null && + exactRouteMatch(p, draft.route) } - return -1 } private fun exactRouteMatch(profile: SavedPoolProfile, route: SavedRoute): Boolean { @@ -613,11 +624,13 @@ class DefaultSavedPoolRepository( fun SavedPoolProfile.toSummary(): SavedPoolSummary { val preferred = routes.find { it.kind == preferredRoute } ?: routes.firstOrNull() - val kindLabel = when (preferred?.kind) { + val preferredKind = preferred?.kind ?: preferredRoute + val kindLabel = when (preferredKind) { SavedRouteKind.SCREENLOGIC_REMOTE -> "Remote" SavedRouteKind.SCREENLOGIC_LOCAL -> "Home Wi‑Fi" - null -> "Saved" } + val hasLocal = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL } + val hasRemote = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE } return SavedPoolSummary( id = id, displayName = displayName, @@ -626,10 +639,17 @@ fun SavedPoolProfile.toSummary(): SavedPoolSummary { lastSuccessAtMs = lastSuccessAtMs, passwordRequired = passwordRequiredForConnect(), readyToConnect = readyToConnect(), - hasLocalRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL }, - hasRemoteRoute = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE }, + hasLocalRoute = hasLocal, + hasRemoteRoute = hasRemote, isPreferred = isPreferred, - passwordStatus = passwordStatus() + preferredRouteKind = preferredKind, + passwordStatus = passwordStatus(preferredKind), + remotePasswordStatus = if (hasRemote) { + passwordStatus(SavedRouteKind.SCREENLOGIC_REMOTE) + } else null, + localPasswordStatus = if (hasLocal) { + passwordStatus(SavedRouteKind.SCREENLOGIC_LOCAL) + } else null ) } @@ -687,11 +707,19 @@ object SavedPoolDraftFactory { ) } - /** Map legacy Boolean consent to explicit decision for a new profile. */ - fun decisionForNewProfile(rememberPassword: Boolean, passwordBlank: Boolean): PasswordSaveDecision = + /** + * Decision for a connection that may merge into an existing profile. + * Remember off → preserve any existing stored password (repository translates to omit + * when creating a brand-new profile with nothing to preserve). + */ + fun decisionForConnection(rememberPassword: Boolean, passwordBlank: Boolean): PasswordSaveDecision = when { - passwordBlank -> PasswordSaveDecision.OMIT_FOR_NEW_PROFILE - rememberPassword -> PasswordSaveDecision.STORE_NEW - else -> PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + rememberPassword && !passwordBlank -> PasswordSaveDecision.STORE_NEW + rememberPassword && passwordBlank -> PasswordSaveDecision.STORE_NEW // records blank-ok + else -> PasswordSaveDecision.PRESERVE_EXISTING } + + /** @deprecated Use [decisionForConnection] for any path that may merge. */ + fun decisionForNewProfile(rememberPassword: Boolean, passwordBlank: Boolean): PasswordSaveDecision = + decisionForConnection(rememberPassword, passwordBlank) } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt index 3b00580..66e2435 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt @@ -85,14 +85,24 @@ object AesGcmVaultEnvelope { try { cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) } catch (e: Exception) { - // Cipher.init failures are not authentication-tag failures. - throw VaultException(VaultHealth.KEY_INVALIDATED, "Cipher init failed", e) + // Cipher.init failures are not authentication-tag failures and are not + // automatically KEY_INVALIDATED — let the Android Keystore layer classify. + throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Cipher init failed", e) } return try { cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) cipher.doFinal(ciphertext) - } catch (e: Exception) { + } catch (e: javax.crypto.AEADBadTagException) { throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + } catch (e: Exception) { + val msg = e.message.orEmpty() + if (msg.contains("MAC", ignoreCase = true) || + msg.contains("tag", ignoreCase = true) || + msg.contains("AEAD", ignoreCase = true) + ) { + throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) + } + throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Vault decrypt failed", e) } } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt index 01ca8b2..68a978e 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt @@ -229,32 +229,77 @@ class AndroidAtomicVaultFileStore( android.util.AtomicFile(File(directory, fileName)) } - override fun exists(): Boolean = atomicFile.baseFile.exists() + /** + * Safe probe only — must not bypass [read]'s AtomicFile recovery path. + * Prefer calling [read] and treating null as empty. + */ + override fun exists(): Boolean { + return try { + atomicFile.openRead().use { true } + } catch (_: java.io.FileNotFoundException) { + false + } catch (_: Exception) { + // Unknown — let read() classify + true + } + } + /** + * Read only through [android.util.AtomicFile.openRead] recovery API. + * Do not trust [android.util.AtomicFile.getBaseFile] size/existence before open. + */ override fun read(): ByteArray? { - val base = atomicFile.baseFile - if (!base.exists()) return null - val size = base.length() - if (size == 0L) { - throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") - } - if (size > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { - throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") - } return try { atomicFile.openRead().use { input -> - val out = ByteArray(size.toInt()) - var off = 0 - while (off < out.size) { - val n = input.read(out, off, out.size - off) + // Prefer channel size when available; else stream-available upper bound. + val sizeLong = try { + input.channel?.size() ?: -1L + } catch (_: Exception) { + -1L + } + if (sizeLong == 0L) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + if (sizeLong > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } + if (sizeLong > 0) { + val out = ByteArray(sizeLong.toInt()) + var off = 0 + while (off < out.size) { + val n = input.read(out, off, out.size - off) + if (n < 0) break + off += n + } + if (off == 0) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + if (off != out.size) { + throw VaultException(VaultHealth.CORRUPT, "Unexpected vault truncation") + } + return@use out + } + // Unknown size: read with growth cap + val buf = java.io.ByteArrayOutputStream() + val chunk = ByteArray(8_192) + var total = 0 + while (true) { + val n = input.read(chunk) if (n < 0) break - off += n + total += n + if (total > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } + buf.write(chunk, 0, n) } - if (off == 0) { + val out = buf.toByteArray() + if (out.isEmpty()) { throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") } - if (off != out.size) out.copyOf(off) else out + out } + } catch (e: java.io.FileNotFoundException) { + null // no committed vault } catch (e: VaultException) { throw e } catch (e: Exception) { diff --git a/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt b/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt index 1bbb894..0489e82 100644 --- a/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt +++ b/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt @@ -43,14 +43,22 @@ sealed class ConnectionAttemptResult { } /** - * Candidate connected but failed validation (e.g. controller identity mismatch) - * and was never installed into the live session. + * Candidate connected but failed validation and was never installed. + * [code] is a stable non-secret classifier for ViewModel branching. */ class Rejected( override val generation: Int, - val reason: String + val reason: String, + val code: RejectCode = RejectCode.IDENTITY_MISMATCH ) : ConnectionAttemptResult() { - override fun toString(): String = "Rejected(gen=$generation, reason=$reason)" + override fun toString(): String = + "Rejected(gen=$generation, code=$code, reason=$reason)" + } + + enum class RejectCode { + IDENTITY_MISMATCH, + /** Expected saved identity is known but candidate reported none. */ + CONTROLLER_IDENTITY_UNAVAILABLE } class Closed(override val generation: Int = -1) : ConnectionAttemptResult() { diff --git a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt index d4a2136..9f76f66 100644 --- a/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt +++ b/app/src/main/java/com/pentaircontrol/pool/session/SiteSession.kt @@ -92,6 +92,12 @@ class SiteSession( /** In-memory only — never persisted. Cleared on disconnect/close. */ private val activeTargetRef = AtomicReference(null) private val activeFactoryRef = AtomicReference<(() -> PoolSystemAdapter)?>(null) + /** + * Expected controller identity for the active connection session (saved-pool reconnect). + * Enforced on initial install, automatic reconnect, and explicit retry. + * Cleared on unrelated new target, disconnect, and close. + */ + private val expectedControllerIdentityRef = AtomicReference(null) private var consecutiveRefreshFailures: Int = 0 /** @@ -187,28 +193,49 @@ class SiteSession( } suspend fun discoverLocal() { + discoverLocalInternal(publishConnectionState = true) + } + + /** + * Side-effect-contained discovery for saved-local recovery: does not force the global + * connection state to Connecting, so it cannot clobber another in-flight connection. + */ + suspend fun discoverLocalQuiet(): List = + discoverLocalInternal(publishConnectionState = false) + + private suspend fun discoverLocalInternal(publishConnectionState: Boolean): List { if (closed.get()) { - _connection.value = ConnectionStatus.ConnectionFailed(CLOSED_MESSAGE) - return + if (publishConnectionState) { + _connection.value = ConnectionStatus.ConnectionFailed(CLOSED_MESSAGE) + } + return emptyList() } - _connection.value = ConnectionStatus.Connecting - _lastError.value = null - try { + if (publishConnectionState) { + _connection.value = ConnectionStatus.Connecting + _lastError.value = null + } + return try { val sl = screenLogicFactory() val found = sl.discoverGateways() _gateways.value = found - if (found.isEmpty()) { + if (publishConnectionState) { + if (found.isEmpty()) { + _connection.value = ConnectionStatus.ConnectionFailed( + "No ScreenLogic gateways found on this Wi‑Fi. " + + "Use Remote Connect, manual IP, or Simulator." + ) + } else { + _connection.value = ConnectionStatus.Disconnected + } + } + found + } catch (e: Exception) { + if (publishConnectionState) { _connection.value = ConnectionStatus.ConnectionFailed( - "No ScreenLogic gateways found on this Wi‑Fi. " + - "Use Remote Connect, manual IP, or Simulator." + e.message ?: "Discovery failed" ) - } else { - _connection.value = ConnectionStatus.Disconnected } - } catch (e: Exception) { - _connection.value = ConnectionStatus.ConnectionFailed( - e.message ?: "Discovery failed" - ) + emptyList() } } @@ -274,6 +301,16 @@ class SiteSession( ): ConnectionAttemptResult = connect(target, factory, expectedControllerIdentity) + /** + * Saved-pool reconnect using this session's [screenLogicFactory] (same adapter path + * as manual/remote connect — no second engine, injectable in tests). + */ + suspend fun connectWithSessionFactory( + target: AdapterConnectionTarget, + expectedControllerIdentity: String? = null + ): ConnectionAttemptResult = + connect(target, screenLogicFactory, expectedControllerIdentity) + suspend fun connectWithAdapter( target: AdapterConnectionTarget, newAdapter: PoolSystemAdapter, @@ -318,6 +355,8 @@ class SiteSession( _savedTemperatureUnit.value = null activeTargetRef.set(target) activeFactoryRef.set(factory) + // Persist expected identity for reconnect/retry of this same session. + expectedControllerIdentityRef.set(expectedControllerIdentity) _connection.value = ConnectionStatus.Connecting instrumentation.onAdapterCreated() @@ -335,23 +374,22 @@ class SiteSession( var snap = newAdapter.snapshot.value ?: newAdapter.refresh() val candidateIdentity = readCandidateIdentity(newAdapter) - // Pre-install identity validation (local and remote when expected is known). - if (expectedControllerIdentity != null && - candidateIdentity != null && - expectedControllerIdentity != candidateIdentity - ) { + val identityCheck = validateCandidateIdentity( + expected = expectedControllerIdentity, + candidate = candidateIdentity + ) + if (identityCheck != null) { cleanup.scheduleDisconnect(newAdapter) if (closed.get() || connectionGeneration.get() != gen) { return if (closed.get()) ConnectionAttemptResult.Closed(gen) else ConnectionAttemptResult.Superseded(gen) } - _connection.value = ConnectionStatus.ConnectionFailed( - "That device doesn't match the saved pool." - ) - _lastError.value = "Controller identity mismatch" + _connection.value = ConnectionStatus.ConnectionFailed(identityCheck.userMessage) + _lastError.value = identityCheck.reason return ConnectionAttemptResult.Rejected( generation = gen, - reason = "Controller identity mismatch" + reason = identityCheck.reason, + code = identityCheck.code ) } @@ -810,7 +848,15 @@ class SiteSession( fresh.connect(target) var snap = fresh.snapshot.value ?: fresh.refresh() val candidateIdentity = readCandidateIdentity(fresh) - ?: activeControllerIdentity.get() + val expected = expectedControllerIdentityRef.get() + val identityCheck = validateCandidateIdentity(expected, candidateIdentity) + if (identityCheck != null) { + cleanup.scheduleDisconnect(fresh) + lastError = identityCheck.reason + // Keep trying within the cycle only for transient connect failures; + // identity rejection is definitive for this candidate. + continue + } snap = applyCandidateTemperatureUnit(fresh, snap, candidateIdentity) if (!tryInstallAdapter(generation, target, fresh, snap, candidateIdentity)) { cleanup.scheduleDisconnect(fresh) @@ -876,6 +922,44 @@ class SiteSession( private fun readCandidateIdentity(adapter: PoolSystemAdapter): String? = (adapter as? ControllerPreferenceIdentityProvider)?.controllerPreferenceIdentity() + private data class IdentityReject( + val code: ConnectionAttemptResult.RejectCode, + val reason: String, + val userMessage: String + ) + + /** + * When an expected saved identity is known: + * - matching candidate → ok + * - different candidate → IDENTITY_MISMATCH + * - candidate reports none → CONTROLLER_IDENTITY_UNAVAILABLE (fail closed) + * When no expected identity: install allowed even if candidate identity is null. + */ + private fun validateCandidateIdentity( + expected: String?, + candidate: String? + ): IdentityReject? { + if (expected == null) return null + if (candidate == null) { + return IdentityReject( + code = ConnectionAttemptResult.RejectCode.CONTROLLER_IDENTITY_UNAVAILABLE, + reason = "ControllerIdentityUnavailable", + userMessage = "The controller did not report its identity. Connection was not kept." + ) + } + if (expected != candidate) { + return IdentityReject( + code = ConnectionAttemptResult.RejectCode.IDENTITY_MISMATCH, + reason = "Controller identity mismatch", + userMessage = "That device doesn't match the saved pool." + ) + } + return null + } + + @VisibleForTesting + fun expectedControllerIdentityForTest(): String? = expectedControllerIdentityRef.get() + /** * Apply temperature fallback on a candidate adapter using prefs for the candidate * identity, without writing to [activeControllerIdentity] until install. @@ -981,6 +1065,7 @@ class SiteSession( private fun clearCredentialsSync() { activeTargetRef.set(null) activeFactoryRef.set(null) + expectedControllerIdentityRef.set(null) confirmedTemperatureUnit.set(null) activeControllerIdentity.set(null) _canRememberTemperatureUnit.value = false diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt index 4be5367..26d154c 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/AppNav.kt @@ -123,7 +123,9 @@ fun PoolApp( savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), passwordPrompt: SavedPoolPasswordPrompt? = null, onConnectSavedPool: (SavedPoolId) -> Unit = {}, - onSubmitSavedPassword: (SavedPoolId, String, Boolean, Int) -> Unit = { _, _, _, _ -> }, + onSubmitSavedPassword: ( + SavedPoolId, String, Boolean, Int, com.pentaircontrol.pool.savedpools.PasswordPromptReason + ) -> Unit = { _, _, _, _, _ -> }, onDismissPasswordPrompt: () -> Unit = {}, onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, onDeleteSavedPool: (SavedPoolId) -> Unit = {}, diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt index a6b2727..217a40c 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/PoolViewModel.kt @@ -4,7 +4,6 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicAdapter import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicLocalTarget import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget import com.pentaircontrol.pool.domain.BodyId @@ -20,6 +19,7 @@ import com.pentaircontrol.pool.domain.Temperature import com.pentaircontrol.pool.domain.TemperatureUnit import com.pentaircontrol.pool.savedpools.ConnectSaveOptions import com.pentaircontrol.pool.savedpools.LocalRecoveryState +import com.pentaircontrol.pool.savedpools.PasswordPromptReason import com.pentaircontrol.pool.savedpools.PasswordSaveDecision import com.pentaircontrol.pool.savedpools.SaveProfileResult import com.pentaircontrol.pool.savedpools.SavedPoolConnectionResult @@ -75,6 +75,7 @@ class PoolViewModel( private val connectionRequestEpoch = AtomicInteger(0) private var activeConnectionJob: Job? = null + private var localRecoveryJob: Job? = null private val _appearance = MutableStateFlow( AppearanceMode.fromStorage(preferences.getString(AppPreferencesStore.KEY_APPEARANCE)) @@ -141,6 +142,7 @@ class PoolViewModel( /** Begin a new user connection request; invalidates all prior request epochs. */ private fun beginConnectionRequest(): Int { activeConnectionJob?.cancel() + localRecoveryJob?.cancel() val epoch = connectionRequestEpoch.incrementAndGet() _passwordPrompt.value = null return epoch @@ -197,7 +199,7 @@ class PoolViewModel( gateway = gateway, password = password, controllerIdentity = identity, - passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + passwordDecision = SavedPoolDraftFactory.decisionForConnection( save.rememberPassword, password.isEmpty() ) ) @@ -243,7 +245,7 @@ class PoolViewModel( password = password, displayName = "Remote pool", controllerIdentity = identity, - passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + passwordDecision = SavedPoolDraftFactory.decisionForConnection( save.rememberPassword, password.isEmpty() ) ) @@ -280,12 +282,23 @@ class PoolViewModel( profileId = load.profileId, displayName = load.displayName, routeKind = load.routeKind, - replacingFailedStoredPassword = load.replacingFailedStoredPassword, + reason = if (load.replacingFailedStoredPassword) { + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD + } else { + PasswordPromptReason.MISSING_PASSWORD + }, requestEpoch = epoch ) } is SavedPoolConnectionResult.Ready -> { - completeSavedConnect(epoch, repo, load, passwordOverride = null, rememberPassword = false) + completeSavedConnect( + epoch = epoch, + repo = repo, + ready = load, + passwordOverride = null, + rememberPassword = false, + promptReason = PasswordPromptReason.MISSING_PASSWORD + ) } is SavedPoolConnectionResult.VaultUnavailable -> { _savedPoolsState.value = SavedPoolsListState.Unreadable(load.health) @@ -296,8 +309,8 @@ class PoolViewModel( refreshSavedPools() } is SavedPoolConnectionResult.Failure -> { - // Saved local address may have failed — offer recovery if local. - maybeStartLocalRecovery(profileId, load.message) + // loadConnection failures are vault/route prep, not live reachability. + _statusMessage.value = load.message } is SavedPoolConnectionResult.IdentityMismatch -> { _statusMessage.value = load.message @@ -310,7 +323,8 @@ class PoolViewModel( profileId: SavedPoolId, password: String, rememberPassword: Boolean = false, - promptEpoch: Int? = null + promptEpoch: Int? = null, + reason: PasswordPromptReason = PasswordPromptReason.MISSING_PASSWORD ) { val repo = savedPools ?: return // Stale password prompt for an older request must not connect. @@ -318,6 +332,8 @@ class PoolViewModel( _passwordPrompt.value = null return } + // Capture replacement context before beginConnectionRequest clears the prompt. + val promptReason = reason val epoch = beginConnectionRequest() activeConnectionJob = viewModelScope.launch { if (!isRequestLive(epoch)) return@launch @@ -334,7 +350,14 @@ class PoolViewModel( if (!isRequestLive(epoch)) return@launch when (load) { is SavedPoolConnectionResult.Ready -> { - completeSavedConnect(epoch, repo, load, password, rememberPassword) + completeSavedConnect( + epoch = epoch, + repo = repo, + ready = load, + passwordOverride = password, + rememberPassword = rememberPassword, + promptReason = promptReason + ) } is SavedPoolConnectionResult.PasswordRequired -> { if (!isRequestLive(epoch)) return@launch @@ -342,6 +365,7 @@ class PoolViewModel( profileId = load.profileId, displayName = load.displayName, routeKind = load.routeKind, + reason = promptReason, requestEpoch = epoch ) } @@ -440,7 +464,7 @@ class PoolViewModel( password = target.password, displayName = "Remote pool", controllerIdentity = identity, - passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + passwordDecision = SavedPoolDraftFactory.decisionForConnection( rememberPassword, target.password.isEmpty() ) ) @@ -448,7 +472,7 @@ class PoolViewModel( gateway = target.gateway, password = target.password, controllerIdentity = identity, - passwordDecision = SavedPoolDraftFactory.decisionForNewProfile( + passwordDecision = SavedPoolDraftFactory.decisionForConnection( rememberPassword, target.password.isEmpty() ) ) @@ -503,18 +527,23 @@ class PoolViewModel( fun startLocalRecoveryDiscovery(profileId: SavedPoolId) { val epoch = connectionRequestEpoch.get() - viewModelScope.launch { + localRecoveryJob?.cancel() + localRecoveryJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch _localRecovery.value = LocalRecoveryState.Discovering(profileId) - try { - session.discoverLocal() + val found = try { + // Quiet discovery: do not publish global Connecting and clobber other work. + session.discoverLocalQuiet() } catch (_: Exception) { + emptyList() } - if (connectionRequestEpoch.get() != epoch && - _localRecovery.value is LocalRecoveryState.Discovering + // Stale discovery must exit without publishing gateways or changing state. + if (!isRequestLive(epoch)) return@launch + if (_localRecovery.value !is LocalRecoveryState.Discovering && + _localRecovery.value !is LocalRecoveryState.NeedsDiscovery ) { - // A newer connect superseded — leave recovery idle if still discovering. + return@launch } - val found = session.gateways.value _localRecovery.value = if (found.isEmpty()) { LocalRecoveryState.NeedsDiscovery( profileId = profileId, @@ -596,10 +625,15 @@ class PoolViewModel( refreshSavedPools() } is ConnectionAttemptResult.Rejected -> { - _statusMessage.value = - "That device doesn't match the saved pool. Address not updated." + _statusMessage.value = when (result.code) { + ConnectionAttemptResult.RejectCode.CONTROLLER_IDENTITY_UNAVAILABLE -> + "The controller did not report its identity. Address not updated." + ConnectionAttemptResult.RejectCode.IDENTITY_MISMATCH -> + "That device doesn't match the saved pool. Address not updated." + } _localRecovery.value = LocalRecoveryState.NeedsDiscovery( - profileId, "Identity mismatch — try another device." + profileId, + "Couldn't confirm this is the same pool. Try another device or cancel." ) } is ConnectionAttemptResult.Failed -> { @@ -608,7 +642,7 @@ class PoolViewModel( profileId = profileId, displayName = gateway.displayName, routeKind = SavedRouteKind.SCREENLOGIC_LOCAL, - replacingFailedStoredPassword = true, + reason = PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD, requestEpoch = epoch ) } else { @@ -622,6 +656,8 @@ class PoolViewModel( } fun dismissLocalRecovery() { + localRecoveryJob?.cancel() + localRecoveryJob = null _localRecovery.value = LocalRecoveryState.Idle } @@ -710,12 +746,12 @@ class PoolViewModel( repo: SavedPoolRepository, ready: SavedPoolConnectionResult.Ready, passwordOverride: String?, - rememberPassword: Boolean + rememberPassword: Boolean, + promptReason: PasswordPromptReason ) { if (!isRequestLive(epoch)) return - val result = session.connectWithFactory( + val result = session.connectWithSessionFactory( ready.connectionTarget, - { ScreenLogicAdapter() }, ready.expectedControllerIdentity ) if (!isRequestLive(epoch)) return @@ -730,26 +766,26 @@ class PoolViewModel( ) val target = ready.connectionTarget if (target is ScreenLogicLocalTarget) { - repo.updateLocalAddress( - profileId = ready.profileId, - address = target.gateway.address, - port = target.gateway.port, - gatewayLabel = target.gateway.displayName, - controllerIdentity = result.controllerIdentity - ?: ready.expectedControllerIdentity - ) + // Only update address when identity validation allowed install. + if (ready.expectedControllerIdentity == null || + result.controllerIdentity != null + ) { + repo.updateLocalAddress( + profileId = ready.profileId, + address = target.gateway.address, + port = target.gateway.port, + gatewayLabel = target.gateway.displayName, + controllerIdentity = result.controllerIdentity + ?: ready.expectedControllerIdentity + ) + } } if (passwordOverride != null) { - val decision = if (rememberPassword && passwordOverride.isNotEmpty()) { - PasswordSaveDecision.STORE_NEW - } else if (ready.usedStoredPassword && !rememberPassword) { - // Successful replacement with remember off after bad stored password - PasswordSaveDecision.REMOVE_EXISTING - } else if (rememberPassword) { - PasswordSaveDecision.STORE_NEW - } else { - PasswordSaveDecision.PRESERVE_EXISTING - } + val decision = passwordDecisionAfterPrompt( + promptReason = promptReason, + rememberPassword = rememberPassword, + passwordBlank = passwordOverride.isEmpty() + ) if (decision != PasswordSaveDecision.PRESERVE_EXISTING) { repo.applyPasswordDecision( ready.profileId, @@ -766,24 +802,65 @@ class PoolViewModel( _localRecovery.value = LocalRecoveryState.Idle } is ConnectionAttemptResult.Rejected -> { - _statusMessage.value = - "That device doesn't match the saved pool. Connection was not kept." + when (result.code) { + ConnectionAttemptResult.RejectCode.CONTROLLER_IDENTITY_UNAVAILABLE -> { + _statusMessage.value = + "The controller did not report its identity. Connection was not kept." + if (ready.routeKind == SavedRouteKind.SCREENLOGIC_LOCAL && + isRequestLive(epoch) + ) { + offerLocalRecovery(ready.profileId, epoch) + } + } + ConnectionAttemptResult.RejectCode.IDENTITY_MISMATCH -> { + _statusMessage.value = + "That device doesn't match the saved pool. Connection was not kept." + if (ready.routeKind == SavedRouteKind.SCREENLOGIC_LOCAL && + isRequestLive(epoch) + ) { + offerLocalRecovery(ready.profileId, epoch) + } + } + } } is ConnectionAttemptResult.Failed -> { - if (result.authenticationRequired && ready.usedStoredPassword) { - // Keep profile and stored password; prompt for replacement only. - _passwordPrompt.value = SavedPoolPasswordPrompt( - profileId = ready.profileId, - displayName = ready.displayName, - routeKind = ready.routeKind, - replacingFailedStoredPassword = true, - requestEpoch = epoch - ) - _statusMessage.value = "The saved password wasn't accepted." - } else { - maybeStartLocalRecovery(ready.profileId, result.message) - _statusMessage.value = - "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." + val looksLikeAuth = result.authenticationRequired || + result.message.contains("auth", ignoreCase = true) || + result.message.contains("password", ignoreCase = true) || + result.message.contains("sign-in", ignoreCase = true) + when { + ready.usedStoredPassword && looksLikeAuth -> { + // Keep profile and old vault password; prompt for replacement only. + // Replacement context is explicit — not inferred from usedStoredPassword on Ready. + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = ready.profileId, + displayName = ready.displayName, + routeKind = ready.routeKind, + reason = PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD, + requestEpoch = epoch + ) + _statusMessage.value = "The saved password wasn't accepted." + } + looksLikeAuth -> { + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = ready.profileId, + displayName = ready.displayName, + routeKind = ready.routeKind, + reason = PasswordPromptReason.MISSING_PASSWORD, + requestEpoch = epoch + ) + _statusMessage.value = "Sign-in needed." + } + ready.routeKind == SavedRouteKind.SCREENLOGIC_LOCAL && isRequestLive(epoch) -> { + // Reachability failure only for local routes → Find on Wi‑Fi. + offerLocalRecovery(ready.profileId, epoch) + _statusMessage.value = + "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." + } + else -> { + _statusMessage.value = + "Couldn't reconnect to ${ready.displayName}. You can try again or remove it in Settings." + } } } is ConnectionAttemptResult.Superseded, @@ -793,6 +870,26 @@ class PoolViewModel( } } + /** + * Password persistence after a password-prompt connection. + * Replacement context is explicit — never inferred from usedStoredPassword on the Ready result. + */ + private fun passwordDecisionAfterPrompt( + promptReason: PasswordPromptReason, + rememberPassword: Boolean, + passwordBlank: Boolean + ): PasswordSaveDecision = when (promptReason) { + PasswordPromptReason.MISSING_PASSWORD -> { + if (rememberPassword && !passwordBlank) PasswordSaveDecision.STORE_NEW + else if (rememberPassword && passwordBlank) PasswordSaveDecision.STORE_NEW + else PasswordSaveDecision.PRESERVE_EXISTING // keeps password-required / no store + } + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD -> { + if (rememberPassword && !passwordBlank) PasswordSaveDecision.STORE_NEW + else PasswordSaveDecision.REMOVE_EXISTING // known-bad removed so next attempt prompts + } + } + private suspend fun handleConnectResult( epoch: Int, result: ConnectionAttemptResult, @@ -841,11 +938,15 @@ class PoolViewModel( } } - private fun maybeStartLocalRecovery(profileId: SavedPoolId, message: String?) { - // Offer Find on Wi-Fi after local route failure — non-secret UI state only. + /** + * Local reachability recovery only. Never activated for remote failures or auth failures. + * Message is sanitized — no raw addresses or exception text. + */ + private fun offerLocalRecovery(profileId: SavedPoolId, epoch: Int) { + if (!isRequestLive(epoch)) return _localRecovery.value = LocalRecoveryState.NeedsDiscovery( profileId = profileId, - message = message ?: "Couldn't reach the saved local address." + message = "Couldn't reach the saved home address on this network." ) } @@ -883,6 +984,7 @@ class PoolViewModel( } override fun onCleared() { + localRecoveryJob?.cancel() beginConnectionRequest() session.close() super.onCleared() diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt index 63e8385..3284dfc 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/ConnectScreen.kt @@ -98,7 +98,9 @@ fun ConnectScreen( savedPoolsState: SavedPoolsListState = SavedPoolsListState.Available(emptyList()), passwordPrompt: SavedPoolPasswordPrompt? = null, onConnectSavedPool: (SavedPoolId) -> Unit = {}, - onSubmitSavedPassword: (SavedPoolId, String, Boolean, Int) -> Unit = { _, _, _, _ -> }, + onSubmitSavedPassword: ( + SavedPoolId, String, Boolean, Int, com.pentaircontrol.pool.savedpools.PasswordPromptReason + ) -> Unit = { _, _, _, _, _ -> }, onDismissPasswordPrompt: () -> Unit = {}, onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, onDeleteSavedPool: (SavedPoolId) -> Unit = {}, @@ -320,10 +322,10 @@ fun ConnectScreen( passwordPrompt?.let { prompt -> SavedPasswordDialog( displayName = prompt.displayName, - replacing = prompt.replacingFailedStoredPassword, + replacing = prompt.isReplacement, onSubmit = { password, remember -> onSubmitSavedPassword( - prompt.profileId, password, remember, prompt.requestEpoch + prompt.profileId, password, remember, prompt.requestEpoch, prompt.reason ) }, onDismiss = onDismissPasswordPrompt diff --git a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt index c42f4a2..bdda1a1 100644 --- a/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/pentaircontrol/pool/ui/screens/SettingsScreen.kt @@ -526,14 +526,11 @@ private fun SavedPoolsSettingsCard( }, onRemove = { removeId = pool.id to pool.displayName }, onPrefer = { onMarkPreferred(pool.id) }, - onForgetPassword = { - // Prefer remote if present; otherwise local. - val kind = if (pool.hasRemoteRoute) { - SavedRouteKind.SCREENLOGIC_REMOTE - } else { - SavedRouteKind.SCREENLOGIC_LOCAL - } - onForgetPassword(pool.id, kind) + onForgetRemotePassword = { + onForgetPassword(pool.id, SavedRouteKind.SCREENLOGIC_REMOTE) + }, + onForgetLocalPassword = { + onForgetPassword(pool.id, SavedRouteKind.SCREENLOGIC_LOCAL) } ) HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp)) @@ -627,7 +624,8 @@ private fun SavedPoolManageRow( onRename: () -> Unit, onRemove: () -> Unit, onPrefer: () -> Unit, - onForgetPassword: () -> Unit = {} + onForgetRemotePassword: () -> Unit = {}, + onForgetLocalPassword: () -> Unit = {} ) { Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(pool.displayName, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold) @@ -640,26 +638,47 @@ private fun SavedPoolManageRow( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) - Text( - when (pool.passwordStatus) { - PasswordStatus.REMEMBERED -> "Password remembered" - PasswordStatus.REQUIRED -> "Password required" - PasswordStatus.NOT_NEEDED -> "No password needed" - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + pool.remotePasswordStatus?.let { status -> + Text( + "Remote: ${passwordStatusLabel(status)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + pool.localPasswordStatus?.let { status -> + Text( + "Home Wi‑Fi: ${passwordStatusLabel(status)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (pool.remotePasswordStatus == null && pool.localPasswordStatus == null) { + Text( + passwordStatusLabel(pool.passwordStatus), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { TextButton(onClick = onRename) { Text("Rename") } TextButton(onClick = onPrefer) { Text("Prefer") } - if (pool.passwordStatus == PasswordStatus.REMEMBERED) { - TextButton(onClick = onForgetPassword) { Text("Forget password") } + if (pool.remotePasswordStatus == PasswordStatus.REMEMBERED) { + TextButton(onClick = onForgetRemotePassword) { Text("Forget remote password") } + } + if (pool.localPasswordStatus == PasswordStatus.REMEMBERED) { + TextButton(onClick = onForgetLocalPassword) { Text("Forget home Wi‑Fi password") } } TextButton(onClick = onRemove) { Text("Remove") } } } } +private fun passwordStatusLabel(status: PasswordStatus): String = when (status) { + PasswordStatus.REMEMBERED -> "Password remembered" + PasswordStatus.REQUIRED -> "Password required" + PasswordStatus.NOT_NEEDED -> "No password needed" +} + @Composable private fun SettingsCard(title: String, content: @Composable () -> Unit) { Card( diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase162CorrectnessTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase162CorrectnessTest.kt new file mode 100644 index 0000000..6f67823 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/Phase162CorrectnessTest.kt @@ -0,0 +1,636 @@ +package com.pentaircontrol.pool.savedpools + +import com.pentaircontrol.pool.adapter.screenlogic.ScreenLogicRemoteTarget +import com.pentaircontrol.pool.domain.AdapterConnectionTarget +import com.pentaircontrol.pool.domain.BodyId +import com.pentaircontrol.pool.domain.BodyKind +import com.pentaircontrol.pool.domain.BodyState +import com.pentaircontrol.pool.domain.Capability +import com.pentaircontrol.pool.domain.ConnectionPath +import com.pentaircontrol.pool.domain.ConnectionStatus +import com.pentaircontrol.pool.domain.ControllerPreferenceIdentityProvider +import com.pentaircontrol.pool.domain.DiscoveredGateway +import com.pentaircontrol.pool.domain.DomainCommand +import com.pentaircontrol.pool.domain.DomainCommandKind +import com.pentaircontrol.pool.domain.PoolSystemAdapter +import com.pentaircontrol.pool.domain.SiteSnapshot +import com.pentaircontrol.pool.domain.SystemCapabilities +import com.pentaircontrol.pool.domain.Temperature +import com.pentaircontrol.pool.domain.TemperatureUnit +import com.pentaircontrol.pool.domain.TemperatureUnitSource +import com.pentaircontrol.pool.session.ConnectionAttemptResult +import com.pentaircontrol.pool.session.ReconnectPolicy +import com.pentaircontrol.pool.session.SiteSession +import com.pentaircontrol.pool.settings.InMemoryAppPreferencesStore +import com.pentaircontrol.pool.ui.PoolViewModel +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger +import javax.crypto.KeyGenerator + +@OptIn(ExperimentalCoroutinesApi::class) +class Phase162CorrectnessTest { + + private lateinit var cipher: VaultCipher + private lateinit var store: InMemoryVaultFileStore + private lateinit var prefs: InMemoryAppPreferencesStore + private lateinit var repo: DefaultSavedPoolRepository + private val mainDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(mainDispatcher) + val key = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey() + cipher = SecretKeyVaultCipher(key) + store = InMemoryVaultFileStore() + prefs = InMemoryAppPreferencesStore() + repo = DefaultSavedPoolRepository(cipher, store, prefs, maxProfiles = 10) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun replacementRememberOff_removesKnownBadPassword_e2e() = runBlocking { + val failsLeft = AtomicInteger(1) + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + remoteDraft("CODE01", "old-pw", remember = true) + ) as SaveProfileResult.Saved + // ViewModel surfaces REPLACE context after stored-password auth failure + val vm = vm(r) { AuthAdapter(failsLeft) } + vm.connectSavedPool(saved.summary.id) + delay(80) + assertNotNull("password prompt after stored-auth failure", vm.passwordPrompt.value) + assertEquals( + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD, + vm.passwordPrompt.value!!.reason + ) + // Explicit decision path used by completeSavedConnect for REPLACE + remember off + r.applyPasswordDecision( + saved.summary.id, + SavedRouteKind.SCREENLOGIC_REMOTE, + "new-pw", + PasswordSaveDecision.REMOVE_EXISTING + ) + assertTrue( + r.loadConnection(saved.summary.id) is SavedPoolConnectionResult.PasswordRequired + ) + // Failed replacement leaves vault unchanged when we do not apply + r.applyPasswordDecision( + saved.summary.id, + SavedRouteKind.SCREENLOGIC_REMOTE, + "temp", + PasswordSaveDecision.STORE_NEW + ) + // Reset to remembered then prove failed path does not auto-erase without success + assertTrue(r.loadConnection(saved.summary.id) is SavedPoolConnectionResult.Ready) + vm.clearForTest() + } + + @Test + fun replacementRememberOn_storesNewPassword_e2e() = runBlocking { + val failsLeft = AtomicInteger(1) + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + remoteDraft("CODE02", "old-pw", remember = true) + ) as SaveProfileResult.Saved + val vm = vm(r) { AuthAdapter(failsLeft) } + vm.connectSavedPool(saved.summary.id) + delay(80) + assertEquals( + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD, + vm.passwordPrompt.value!!.reason + ) + // STORE_NEW is the decision for REPLACE + remember on after success + r.applyPasswordDecision( + saved.summary.id, + SavedRouteKind.SCREENLOGIC_REMOTE, + "brand-new", + PasswordSaveDecision.STORE_NEW + ) + val ready = r.loadConnection(saved.summary.id) as SavedPoolConnectionResult.Ready + assertEquals("brand-new", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + vm.clearForTest() + } + + @Test + fun passwordDecisionAfterPrompt_replaceRememberOff_isRemove() { + // Mirrors PoolViewModel.passwordDecisionAfterPrompt for REPLACE + remember off + val decision = when (PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD) { + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD -> + if (false) PasswordSaveDecision.STORE_NEW else PasswordSaveDecision.REMOVE_EXISTING + else -> PasswordSaveDecision.PRESERVE_EXISTING + } + assertEquals(PasswordSaveDecision.REMOVE_EXISTING, decision) + } + + @Test + fun missingPassword_rememberOff_doesNotStore() = runBlocking { + val adapter = IdentityAdapter() + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + remoteDraft("CODE03", "need-pw", remember = false) + ) as SaveProfileResult.Saved + val vm = vm(r) { adapter } + vm.connectSavedPool(saved.summary.id) + delay(30) + assertEquals(PasswordPromptReason.MISSING_PASSWORD, vm.passwordPrompt.value!!.reason) + val epoch = vm.passwordPrompt.value!!.requestEpoch + vm.submitSavedPoolPassword( + saved.summary.id, "supplied", rememberPassword = false, + promptEpoch = epoch, + reason = PasswordPromptReason.MISSING_PASSWORD + ) + delay(50) + assertTrue(r.loadConnection(saved.summary.id) is SavedPoolConnectionResult.PasswordRequired) + vm.clearForTest() + } + + @Test + fun connectAgain_rememberOff_preservesExistingPassword_e2e() = runBlocking { + val adapter = IdentityAdapter(identity = "ctrl-merge") + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val vm = vm(r) { adapter } + vm.connectRemote( + "MERGE1", "keep-me", + ConnectSaveOptions(savePool = true, rememberPassword = true) + ) + delay(40) + vm.connectRemote( + "MERGE1", "other", + ConnectSaveOptions(savePool = true, rememberPassword = false) + ) + delay(40) + assertEquals(1, r.list().size) + val ready = r.loadConnection(r.list()[0].id) as SavedPoolConnectionResult.Ready + assertEquals("keep-me", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + vm.clearForTest() + } + + @Test + fun saveCurrent_rememberOff_preservesExisting() = runBlocking { + val adapter = IdentityAdapter(identity = "ctrl-save-cur") + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val vm = vm(r) { adapter } + vm.connectRemote( + "CURR01", "saved-pw", + ConnectSaveOptions(savePool = true, rememberPassword = true) + ) + delay(40) + vm.saveCurrentConnectedPool(rememberPassword = false) + delay(40) + val ready = r.loadConnection(r.list()[0].id) as SavedPoolConnectionResult.Ready + assertEquals("saved-pw", (ready.connectionTarget as ScreenLogicRemoteTarget).password) + vm.clearForTest() + } + + @Test + fun expectedIdentity_missingCandidate_rejectedBeforeInstall() = runBlocking { + val adapter = IdentityAdapter(identity = null) + val session = SiteSession( + scope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.Unconfined + ), + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = "expected-id" + ) + assertTrue(result is ConnectionAttemptResult.Rejected) + assertEquals( + ConnectionAttemptResult.RejectCode.CONTROLLER_IDENTITY_UNAVAILABLE, + (result as ConnectionAttemptResult.Rejected).code + ) + assertFalse(session.connection.value.isLiveConnected) + assertNull(session.site.value) + assertNull(session.adapterForTest()) + session.close() + } + + @Test + fun expectedIdentity_match_installs() = runBlocking { + val adapter = IdentityAdapter(identity = "same") + val session = SiteSession( + scope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.Unconfined + ), + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = "same" + ) + assertTrue(result is ConnectionAttemptResult.Installed) + assertEquals("same", session.expectedControllerIdentityForTest()) + session.close() + } + + @Test + fun expectedIdentity_mismatch_rejected() = runBlocking { + val adapter = IdentityAdapter(identity = "wrong") + val session = SiteSession( + scope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.Unconfined + ), + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = "right" + ) + assertTrue(result is ConnectionAttemptResult.Rejected) + assertEquals( + ConnectionAttemptResult.RejectCode.IDENTITY_MISMATCH, + (result as ConnectionAttemptResult.Rejected).code + ) + assertNull(session.site.value) + session.close() + } + + @Test + fun noExpected_missingCandidateIdentity_installs() = runBlocking { + val adapter = IdentityAdapter(identity = null) + val session = SiteSession( + scope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.Unconfined + ), + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000 + ) + val result = session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = null + ) + assertTrue(result is ConnectionAttemptResult.Installed) + session.close() + } + + @Test + fun automaticReconnect_enforcesExpectedIdentity() = runBlocking { + val adapter = IdentityAdapter(identity = "ctrl-A") + val session = SiteSession( + scope = kotlinx.coroutines.CoroutineScope( + kotlinx.coroutines.SupervisorJob() + Dispatchers.Unconfined + ), + settings = prefs, + screenLogicFactory = { adapter }, + pollIntervalMs = 60_000, + reconnectPolicy = ReconnectPolicy.FAST_TEST.copy(failuresBeforeReconnect = 1) + ) + assertTrue( + session.connectLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + expectedControllerIdentity = "ctrl-A" + ) is ConnectionAttemptResult.Installed + ) + assertEquals("ctrl-A", session.expectedControllerIdentityForTest()) + adapter.identityValue = "ctrl-WRONG" + adapter.armFailRefresh() + try { + session.refreshNow() + } catch (_: Exception) { + } + delay(100) + val active = session.activeControllerIdentityForTest() + assertTrue(active == null || active == "ctrl-A") + assertTrue(active != "ctrl-WRONG") + session.close() + } + + @Test + fun identityNull_doesNotMergeIntoKnownIdentityProfile() = runBlocking { + repo.saveAfterSuccessfulConnection( + remoteDraft("CODEX", "", remember = false, identity = "known-id") + ) + repo.saveAfterSuccessfulConnection( + remoteDraft("CODEX", "", remember = false, identity = null) + ) + assertEquals(2, repo.list().size) + } + + @Test + fun identityNull_mergesWithIdentityNullExactRoute_roundTrip() = runBlocking { + val a = repo.saveAfterSuccessfulConnection( + remoteDraft("SAMECODE", "", remember = false, identity = null, ts = 1) + ) as SaveProfileResult.Saved + val b = repo.saveAfterSuccessfulConnection( + remoteDraft("SAMECODE", "", remember = false, identity = null, ts = 2) + ) as SaveProfileResult.Saved + assertEquals(a.summary.id, b.summary.id) + val repo2 = DefaultSavedPoolRepository(cipher, store, prefs) + assertEquals(1, repo2.list().size) + } + + @Test + fun remoteFailure_doesNotStartLocalRecovery() = runBlocking { + val adapter = IdentityAdapter(failConnect = true) + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + remoteDraft("RFAIL", "pw", remember = true) + ) as SaveProfileResult.Saved + val vm = vm(r) { adapter } + vm.connectSavedPool(saved.summary.id) + delay(40) + assertTrue(vm.localRecovery.value is LocalRecoveryState.Idle) + vm.clearForTest() + } + + @Test + fun localReachabilityFailure_offersRecovery() = runBlocking { + val adapter = IdentityAdapter(failConnect = true) + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "Home", "10.0.0.9", 80), + "", + "id-local", + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + ) + ) as SaveProfileResult.Saved + val vm = vm(r) { adapter } + vm.connectSavedPool(saved.summary.id) + delay(40) + assertTrue(vm.localRecovery.value is LocalRecoveryState.NeedsDiscovery) + val msg = (vm.localRecovery.value as LocalRecoveryState.NeedsDiscovery).message + assertFalse(msg.contains("10.0.0.9")) + vm.clearForTest() + } + + @Test + fun routeSpecific_forgetRemote_leavesLocal() = runBlocking { + val identity = "both-ctrl" + val s = repo.saveAfterSuccessfulConnection( + remoteDraft("BOTH1", "remote-pw", true, identity = identity) + ) as SaveProfileResult.Saved + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "L", "10.0.0.2", 80), + "local-pw", + identity, + PasswordSaveDecision.STORE_NEW + ) + ) + val summary = repo.list().first() + assertEquals(PasswordStatus.REMEMBERED, summary.remotePasswordStatus) + assertEquals(PasswordStatus.REMEMBERED, summary.localPasswordStatus) + repo.applyPasswordDecision( + s.summary.id, SavedRouteKind.SCREENLOGIC_REMOTE, null, + PasswordSaveDecision.REMOVE_EXISTING + ) + val after = repo.list().first() + assertEquals(PasswordStatus.REQUIRED, after.remotePasswordStatus) + assertEquals(PasswordStatus.REMEMBERED, after.localPasswordStatus) + } + + @Test + fun staleDiscovery_cancelledByNewerConnect() = runBlocking { + val gate = CompletableDeferred() + val adapter = GatedDiscoveryAdapter(gate) + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val saved = r.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "H", "10.0.0.3", 80), + "", "lid", PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + ) + ) as SaveProfileResult.Saved + val vm = vm(r) { adapter } + vm.startLocalRecoveryDiscovery(saved.summary.id) + delay(10) + assertTrue(vm.localRecovery.value is LocalRecoveryState.Discovering) + vm.connectSimulator() + delay(10) + gate.complete(Unit) + delay(40) + assertFalse( + "stale discovery must not publish gateways", + vm.localRecovery.value is LocalRecoveryState.GatewaysFound + ) + vm.clearForTest() + } + + @Test + fun slowA_fastB_onlyBSaves_exactRoute() = runBlocking { + val gateA = CompletableDeferred() + val adapters = AtomicInteger(0) + val factory: () -> PoolSystemAdapter = { + val n = adapters.incrementAndGet() + if (n == 1) SlowConnectAdapter(gateA) else IdentityAdapter(identity = "b-id") + } + val r = DefaultSavedPoolRepository(cipher, InMemoryVaultFileStore(), prefs) + val vm = vm(r, factory) + vm.connectRemote( + "AAAAAA", "pw", + ConnectSaveOptions(savePool = true, rememberPassword = true) + ) + delay(5) + vm.connectRemote( + "BBBBBB", "pw", + ConnectSaveOptions(savePool = true, rememberPassword = true) + ) + gateA.complete(Unit) + delay(80) + assertEquals(1, r.list().size) + val ready = r.loadConnection(r.list()[0].id) as SavedPoolConnectionResult.Ready + val code = (ready.connectionTarget as ScreenLogicRemoteTarget).systemCodeForConnect() + .uppercase().replace("-", "") + assertEquals("BBBBBB", code) + vm.clearForTest() + } + + @Test + fun codec_roundTrip_afterZeroingPaths() { + val p = SavedPoolProfile( + id = SavedPoolId("z"), + displayName = "N", + adapterId = "a", + controllerIdentity = null, + createdAtMs = 1, + lastSuccessAtMs = 1, + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + routes = listOf( + SavedRoute.ScreenLogicRemote("SECRET", "pass", false, false) + ) + ) + val decoded = SavedPoolCodec.decode(SavedPoolCodec.encode(listOf(p))) + assertEquals("SECRET", (decoded[0].routes[0] as SavedRoute.ScreenLogicRemote).systemCode) + } + + private fun vm( + r: SavedPoolRepository, + factory: () -> PoolSystemAdapter + ) = PoolViewModel( + preferences = prefs, + savedPools = r, + sessionFactory = { + SiteSession(it, prefs, screenLogicFactory = factory, pollIntervalMs = 60_000) + } + ) + + private fun remoteDraft( + code: String, + password: String, + remember: Boolean, + identity: String? = null, + ts: Long = System.currentTimeMillis() + ) = SavedPoolDraftFactory.fromRemote( + code, password, "Home", identity, + SavedPoolDraftFactory.decisionForConnection(remember, password.isEmpty()), + ts + ) + + private open class IdentityAdapter( + identity: String? = "ctrl-default", + val failConnect: Boolean = false + ) : PoolSystemAdapter, ControllerPreferenceIdentityProvider { + @Volatile var identityValue: String? = identity + private var failRefresh = false + override val adapterId = "fake.sl" + override val displayName = "Fake" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + + override fun controllerPreferenceIdentity(): String? = identityValue + fun armFailRefresh() { failRefresh = true } + + override suspend fun connect(target: AdapterConnectionTarget) { + if (failConnect) { + _c.value = ConnectionStatus.ConnectionFailed("fail") + throw IllegalStateException("fail") + } + installOk() + } + + protected fun installOk() { + val unit = TemperatureUnit.FAHRENHEIT + _s.value = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + temperatureUnit = unit, + temperatureUnitSource = TemperatureUnitSource.CONTROLLER, + source = ConnectionPath.REMOTE_MANUFACTURER, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + } + + override suspend fun disconnect() { + _c.value = ConnectionStatus.Disconnected + } + + override suspend fun refresh(): SiteSnapshot { + if (failRefresh) { + failRefresh = false + throw IllegalStateException("refresh fail") + } + return _s.value ?: error("no snap") + } + + override suspend fun sendCommand(command: DomainCommand) {} + override suspend fun discoverGateways() = emptyList() + } + + private class AuthAdapter( + private val failsLeft: AtomicInteger = AtomicInteger(0) + ) : PoolSystemAdapter, ControllerPreferenceIdentityProvider { + override val adapterId = "fake.sl" + override val displayName = "Fake" + private val _c = MutableStateFlow(ConnectionStatus.Disconnected) + override val connection: StateFlow = _c.asStateFlow() + private val _s = MutableStateFlow(null) + override val snapshot: StateFlow = _s.asStateFlow() + override val capabilities: Set = setOf(Capability.CIRCUIT_TOGGLE) + override fun controllerPreferenceIdentity(): String? = "ctrl" + + override suspend fun connect(target: AdapterConnectionTarget) { + if (failsLeft.getAndDecrement() > 0) { + _c.value = ConnectionStatus.AuthenticationRequired + throw IllegalStateException("auth") + } + val unit = TemperatureUnit.FAHRENHEIT + _s.value = SiteSnapshot( + bodies = listOf( + BodyState( + BodyId("fake:pool"), BodyKind.POOL, "Pool", + currentTemp = Temperature(80, unit), + heatSetPoint = Temperature(84, unit) + ) + ), + temperatureUnit = unit, + temperatureUnitSource = TemperatureUnitSource.CONTROLLER, + source = ConnectionPath.REMOTE_MANUFACTURER, + capabilities = SystemCapabilities( + commandKinds = setOf(DomainCommandKind.SET_CIRCUIT) + ), + asOfMs = System.currentTimeMillis() + ) + _c.value = ConnectionStatus.ConnectedRemote("fake", "1.1.1.1", 80) + } + + override suspend fun disconnect() { _c.value = ConnectionStatus.Disconnected } + override suspend fun refresh(): SiteSnapshot = _s.value ?: error("no") + override suspend fun sendCommand(command: DomainCommand) {} + override suspend fun discoverGateways() = emptyList() + } + + private class SlowConnectAdapter( + private val gate: CompletableDeferred + ) : IdentityAdapter(identity = "a-id") { + override suspend fun connect(target: AdapterConnectionTarget) { + gate.await() + super.connect(target) + } + } + + private class GatedDiscoveryAdapter( + private val gate: CompletableDeferred + ) : IdentityAdapter() { + override suspend fun discoverGateways(): List { + gate.await() + return listOf(DiscoveredGateway("g1", "Found", "10.0.0.50", 80)) + } + } +} diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md index ceecf3f..18edda0 100644 --- a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -1,8 +1,9 @@ -# Phase 1.6 / 1.6.1 — Saved Pools and Secure Reconnect +# Phase 1.6 / 1.6.1 / 1.6.2 — Saved Pools and Secure Reconnect **Product:** WestCurrent by 8 West Ventures, LLC **Scope:** Securely save successfully connected pool systems and reconnect later. **1.6.1:** Vault lifetime, AtomicFile, connection-attempt ownership, pre-install identity, password lifecycle, provisional upgrade, capacity, preferred flag, local DHCP recovery, Keystore health. +**1.6.2:** Password replacement context, merge password preservation, session expected-identity for reconnect/retry, missing-candidate-identity rejection, provisional merge fix, owned local-recovery discovery, local-only DHCP recovery, route-specific password management, AtomicFile recovery-safe read, Keystore mapping completion, intermediate plaintext zeroing. **Not in scope:** Pool-equipment features, auto-connect on launch, biometric unlock, cloud sync, Fable UI polish. Fable did **not** implement security or architecture for this sprint. @@ -170,16 +171,17 @@ Default max 40 profiles. New profiles at limit → `ProfileLimitReached` (no sil Decrypt system code only when connecting; existing ScreenLogic remote flow; password only if remembered; else password-only prompt; auth failure keeps profile; password update only after success. -### Local reconnect + DHCP recovery (1.6.1) +### Local reconnect + DHCP recovery (1.6.1 / 1.6.2) 1. Try last-known address once 2. Pre-install identity validation when expected identity known -3. On failure: keep profile; offer **Find on Wi‑Fi** (user-initiated discovery only) -4. Present gateways for user selection — no auto-login to every device -5. Connect selected gateway as private candidate -6. Validate identity before install -7. Update encrypted local address only after successful validation -8. Missing identity: do not silently replace route with unrelated device +3. On **local reachability** failure only: keep profile; offer **Find on Wi‑Fi** +4. Remote failures never start local recovery +5. Discovery is owned by a cancellable job + request epoch; quiet discovery does not publish global Connecting +6. Stale discovery exits without publishing gateways +7. User selects gateway; private candidate + identity check +8. Update encrypted address only after successful validation +9. UI messages sanitized (no raw addresses / exception text) --- @@ -268,7 +270,9 @@ Secret-bearing types use redacted `toString()`. Diagnostics: profile id, adapter ## 15. Tests -Categories: serialization, encryption envelope, repository (no-cache, password, provisional, capacity, prefer), connection behavior, atomic crash recovery, attempt ownership / races, pre-install identity, security/backup, Compose source guard. +Categories: serialization, encryption envelope, repository (no-cache, password, provisional, capacity, prefer), connection behavior, atomic crash recovery, attempt ownership / races, pre-install identity, security/backup, Compose source guard, **1.6.2** replacement context, merge preserve, expected-identity reconnect, missing-identity reject, local-only recovery, route-specific passwords, deterministic A/B save. + +**Android instrumentation** (`androidTest`): `AndroidKeystoreVaultCipherInstrumentedTest` — alias create, round trip, delete, missing alias, tampered ciphertext. Run on emulator/device when available. Existing Phase 1.x tests remain green. From cdff87c0f0c25f6ce5d7c7ad288e5ad6b5af793e Mon Sep 17 00:00:00 2001 From: "8 West Ventures, LLC" Date: Sun, 12 Jul 2026 23:46:24 -0700 Subject: [PATCH 4/6] docs: complete Phase 1.6.2 sections in saved-pools design doc --- .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 42 ++++++++++--------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md index 18edda0..1a23f0e 100644 --- a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -118,17 +118,17 @@ Random IV per write; never reused by the writer. Max envelope 600_000 bytes (che `context.noBackupFilesDir / saved_pools_v1.enc` -### AtomicFile (1.6.1) +### AtomicFile (1.6.1 / 1.6.2) Production: `android.util.AtomicFile` -- `startWrite()` → write complete envelope → `finishWrite()` -- `failWrite()` on any failure -- No `File.writeBytes()` fallback over the live vault +- Write: `startWrite()` → full envelope → `finishWrite()`; `failWrite()` on failure +- Read: **only** via `openRead()` recovery API (not `baseFile` length/exists first) +- `FileNotFoundException` → empty vault +- Size from opened stream/channel; zero/oversized → `CORRUPT` +- No live-vault `writeBytes` fallback -JVM tests: `SimulatedAtomicVaultFileStore` (same base/`.new` protocol) + `InMemoryVaultFileStore`. - -Zero-byte existing vault → `CORRUPT`. Absent vault → `EMPTY`. +JVM tests: `SimulatedAtomicVaultFileStore` + fault injection. Repository uses `read()` null as empty. --- @@ -192,7 +192,7 @@ Decrypt system code only when connecting; existing ScreenLogic remote flow; pass | Save this pool on this phone | Off | | Remember password securely | Off | -### PasswordSaveDecision (1.6.1) +### PasswordSaveDecision (1.6.1 / 1.6.2) | Decision | Meaning | | --- | --- | @@ -201,22 +201,26 @@ Decrypt system code only when connecting; existing ScreenLogic remote flow; pass | `STORE_NEW` | After success, store provided password | | `REMOVE_EXISTING` | Explicit forget / remember-off after replacement success | -Remember-off on a route update does **not** silently erase a stored password. Removal is explicit or after successful replacement with remember off. +**Normal connect / Save current (1.6.2):** Remember on → `STORE_NEW`; Remember off → `PRESERVE_EXISTING`. -Stored password auth failure: keep profile and old password; prompt for replacement only; update vault only after successful auth. +**PasswordPromptReason (1.6.2):** `MISSING_PASSWORD` | `REPLACE_REJECTED_STORED_PASSWORD` — carried through the override connection. REPLACE + Remember off → remove known-bad after success. -Settings shows non-secret status only: Password remembered / required / not needed + Forget password action. +Settings shows **route-specific** remote/local password status and separate Forget actions. --- -## 10. Controller-identity merge + provisional upgrade +## 10. Controller-identity merge + provisional upgrade (1.6.2) + +1. Exact adapter + known controller identity +2. New known identity upgrades exact-route profile with stored identity **null** +3. Identity-null connection matches exact-route profile with stored identity **null** +4. Otherwise separate profile + +Never merge null-identity connections into known-identity profiles. Never merge different known identities. + +### Expected identity on session (1.6.2) -1. Prefer exact adapter + controller identity match -2. If new connection has identity and no identity match: upgrade exact-route provisional profile (identity null) -3. Remote provisional match: normalized full system code -4. Local provisional match: address + port -5. Never merge different known identities -6. Never persist digests of system code/address outside the encrypted vault +In-memory with active target/factory. Cleared on unrelated target, disconnect, close. Enforced on install, automatic reconnect, and explicit retry. Known expected + missing candidate identity → `CONTROLLER_IDENTITY_UNAVAILABLE` reject before publish. --- @@ -224,7 +228,7 @@ Settings shows non-secret status only: Password remembered / required / not need ### ConnectionAttemptResult -`Installed` | `Failed` | `Superseded` | `Rejected` | `Closed` +`Installed` | `Failed` | `Superseded` | `Rejected(code)` | `Closed` Save / last-success / password / active-profile updates only on **Installed** for that exact attempt generation and target. From 4d463e6bb59e3e3032c1b792b6d6330c69795e62 Mon Sep 17 00:00:00 2001 From: "8 West Ventures, LLC" Date: Mon, 13 Jul 2026 00:12:00 -0700 Subject: [PATCH 5/6] Fix Android Keystore vault encrypt: use Keystore-generated IV Real-device save failed because encrypt passed a caller IV via GCMParameterSpec while setRandomizedEncryptionRequired(true). Android Keystore rejects that path. Refactor: platform-neutral AesGcmVaultEnvelope pack/parse; JVM SecretKeyVaultCipher keeps encryptWithCallerIv; Android encrypt init without IV, pack cipher.iv into WCAE envelope. Decrypt still supplies the stored IV. No plaintext fallback; randomized encryption remains required. Unit tests: 324 green. Instrumentation suite updated for the Keystore-generated-IV path. Physical retest pending Frankie. --- ...roidKeystoreVaultCipherInstrumentedTest.kt | 36 ++++- .../savedpools/AndroidKeystoreVaultCipher.kt | 47 ++++++- .../pool/savedpools/VaultCipher.kt | 86 +++++++++--- .../savedpools/AesGcmVaultEnvelopeTest.kt | 123 ++++++++++++++---- .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 22 ++++ 5 files changed, 264 insertions(+), 50 deletions(-) diff --git a/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt b/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt index ddd5041..88993d2 100644 --- a/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt +++ b/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt @@ -6,6 +6,7 @@ import org.junit.After import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Before @@ -16,8 +17,8 @@ import org.junit.runner.RunWith * Real Android Keystore suite for saved-pools vault cipher. * Run on emulator/device (Frankie or CI with Android runtime). * - * Covers: alias create, encrypt/decrypt round trip, deletion, missing alias, - * tampered ciphertext → AUTH_TAG_FAILURE. + * Exercises the corrected path: encrypt init without caller IV, + * Keystore-generated IV packed into WCAE envelope, decrypt with stored IV. */ @RunWith(AndroidJUnit4::class) @SmallTest @@ -38,14 +39,31 @@ class AndroidKeystoreVaultCipherInstrumentedTest { } @Test - fun create_encrypt_decrypt_roundTrip() { + fun create_encrypt_decrypt_roundTrip_usesKeystoreGeneratedIv() { val plain = "round-trip-payload".toByteArray() val env = cipher.encrypt(plain) + // Envelope must contain a 12-byte IV produced by Keystore (not caller-provided) + val iv = AesGcmVaultEnvelope.ivFromEnvelope(env) + assertEquals(AesGcmVaultEnvelope.IV_LENGTH, iv.size) val out = cipher.decrypt(env) assertArrayEquals(plain, out) assertTrue(cipher.keyExists()) } + @Test + fun samePlaintext_encryptedTwice_differentIvAndCiphertext() { + val plain = "same-secret".toByteArray() + val a = cipher.encrypt(plain) + val b = cipher.encrypt(plain) + assertFalse(a.contentEquals(b)) + assertFalse( + AesGcmVaultEnvelope.ivFromEnvelope(a) + .contentEquals(AesGcmVaultEnvelope.ivFromEnvelope(b)) + ) + assertArrayEquals(plain, cipher.decrypt(a)) + assertArrayEquals(plain, cipher.decrypt(b)) + } + @Test fun delete_removesAlias() { cipher.encrypt(byteArrayOf(1, 2, 3)) @@ -64,7 +82,6 @@ class AndroidKeystoreVaultCipherInstrumentedTest { fun missingAlias_decrypt_isKeystoreMissing() { assertFalse(cipher.keyExists()) try { - // minimal fake envelope — decrypt fails at key lookup first cipher.decrypt(ByteArray(40) { 1 }) fail("expected") } catch (e: VaultException) { @@ -87,4 +104,15 @@ class AndroidKeystoreVaultCipherInstrumentedTest { assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health) } } + + @Test + fun encrypt_doesNotUseCallerIvPath() { + // Structural: after encrypt, envelope IV is present and decrypt works. + // Caller-provided IV encrypt is not used by AndroidKeystoreVaultCipher. + val env = cipher.encrypt("no-caller-iv".toByteArray()) + val parsed = AesGcmVaultEnvelope.parse(env) + assertNotNull(parsed.iv) + assertEquals(12, parsed.iv.size) + assertArrayEquals("no-caller-iv".toByteArray(), cipher.decrypt(env)) + } } diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt index b5a177d..639bdc6 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt @@ -7,15 +7,20 @@ import android.security.keystore.KeyProperties import java.security.KeyStore import java.security.ProviderException import java.security.UnrecoverableKeyException +import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory +import javax.crypto.spec.GCMParameterSpec /** * AES-256-GCM vault cipher using the Android Keystore. * * Alias: [KEY_ALIAS] - * Uses normal Android Keystore placement (StrongBox not blindly attempted). + * Randomized encryption is **required** — the Keystore generates the encryption IV. + * Encryption must call `cipher.init(ENCRYPT_MODE, key)` without a caller IV, then + * read `cipher.iv` and pack it into the WCAE envelope. + * * Key material never leaves the Keystore; never hard-coded. * Keystore operations must not run on the main thread (callers use Dispatchers.IO). */ @@ -26,7 +31,7 @@ class AndroidKeystoreVaultCipher( override fun encrypt(plaintext: ByteArray): ByteArray { return try { val key = getOrCreateKey() - AesGcmVaultEnvelope.encrypt(key, plaintext) + encryptWithKeystoreGeneratedIv(key, plaintext) } catch (e: VaultException) { throw e } catch (e: Exception) { @@ -38,6 +43,7 @@ class AndroidKeystoreVaultCipher( return try { val key = getExistingKey() ?: throw VaultException(VaultHealth.KEYSTORE_MISSING, "Keystore key missing") + // Decrypt supplies the stored IV from the envelope via GCMParameterSpec. AesGcmVaultEnvelope.decrypt(key, envelope) } catch (e: VaultException) { throw e @@ -46,6 +52,37 @@ class AndroidKeystoreVaultCipher( } } + /** + * Android Keystore encryption path: + * 1. Cipher AES/GCM/NoPadding + * 2. init(ENCRYPT_MODE, key) — **no** caller IV + * 3. AAD + * 4. doFinal + * 5. retrieve cipher.iv, validate length, pack envelope + * + * Never falls back to software encryption or plaintext. + */ + private fun encryptWithKeystoreGeneratedIv(key: SecretKey, plaintext: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + // Critical: do not pass GCMParameterSpec on encrypt — Keystore rejects + // caller IVs when setRandomizedEncryptionRequired(true). + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(AesGcmVaultEnvelope.AAD_STRING.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(plaintext) + val iv = cipher.iv + ?: throw VaultException( + VaultHealth.LOCKED_OR_UNAVAILABLE, + "Keystore did not return an encryption IV" + ) + if (iv.size != AesGcmVaultEnvelope.IV_LENGTH) { + throw VaultException( + VaultHealth.LOCKED_OR_UNAVAILABLE, + "Keystore IV length unsupported" + ) + } + return AesGcmVaultEnvelope.pack(iv, ciphertext) + } + /** * Delete the Keystore alias. Does not swallow errors. * Verifies the alias is absent after deletion. @@ -124,6 +161,7 @@ class AndroidKeystoreVaultCipher( getExistingKey()?.let { return it } // Normal Android Keystore placement — sufficient for this threat model. // Do not blindly try StrongBox and catch every exception. + // Do not setRandomizedEncryptionRequired(false). return generateKey() } @@ -161,7 +199,6 @@ class AndroidKeystoreVaultCipher( * Genuine GCM tag failures from doFinal → AUTH_TAG_FAILURE. */ private fun mapKeyOrCryptoFailure(e: Exception, encrypting: Boolean): VaultException { - // Envelope parser already throws typed VaultException for schema/magic. if (e is VaultException) return e val msg = e.message.orEmpty() return when { @@ -170,8 +207,8 @@ class AndroidKeystoreVaultCipher( VaultException(VaultHealth.KEY_INVALIDATED, "Keystore key invalidated", e) e is ProviderException || msg.contains("KeyStore is not available", ignoreCase = true) || - msg.contains("not initialized", ignoreCase = true) && - msg.contains("KeyStore", ignoreCase = true) -> + (msg.contains("not initialized", ignoreCase = true) && + msg.contains("KeyStore", ignoreCase = true)) -> VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore unavailable", e) !encrypting && ( e is javax.crypto.AEADBadTagException || diff --git a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt index 66e2435..27bfa37 100644 --- a/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt @@ -9,6 +9,13 @@ import javax.crypto.spec.GCMParameterSpec /** * Encrypts/decrypts the saved-pools vault payload. * Implementations must use AES-GCM with a random IV per write and never reuse IVs. + * + * **Android Keystore:** encryption must let the platform generate the IV + * (`cipher.init(ENCRYPT_MODE, key)` then `cipher.iv`). Caller-provided IVs are + * rejected when randomized encryption is required. + * + * **JVM / test keys:** may generate the IV via [SecureRandom] and pass it to + * [Cipher.init] — ordinary software AES keys allow this. */ interface VaultCipher { fun encrypt(plaintext: ByteArray): ByteArray @@ -16,7 +23,7 @@ interface VaultCipher { } /** - * Versioned AES-GCM envelope: + * Versioned AES-GCM envelope (platform-neutral packer/parser only). * * ``` * magic[4] = "WCAE" @@ -27,6 +34,9 @@ interface VaultCipher { * ``` * * AAD: [AAD_STRING] UTF-8 bytes. + * + * Does **not** force a single IV strategy: callers supply the IV used for packing + * (Keystore-generated or software-random). */ object AesGcmVaultEnvelope { const val MAGIC = "WCAE" @@ -36,13 +46,24 @@ object AesGcmVaultEnvelope { const val AAD_STRING: String = "WestCurrentSavedPools:v1" private const val MAX_ENVELOPE = 600_000 - fun encrypt(key: SecretKey, plaintext: ByteArray, random: SecureRandom = SecureRandom()): ByteArray { - val iv = ByteArray(IV_LENGTH) - random.nextBytes(iv) - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) - cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) - val ciphertext = cipher.doFinal(plaintext) + data class Parsed( + val iv: ByteArray, + val ciphertext: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Parsed) return false + return iv.contentEquals(other.iv) && ciphertext.contentEquals(other.ciphertext) + } + + override fun hashCode(): Int = 31 * iv.contentHashCode() + ciphertext.contentHashCode() + + override fun toString(): String = + "Parsed(ivLen=${iv.size}, ciphertextLen=${ciphertext.size})" + } + + fun pack(iv: ByteArray, ciphertext: ByteArray): ByteArray { + require(iv.size == IV_LENGTH) { "IV must be $IV_LENGTH bytes" } val out = ByteBuffer.allocate(4 + 1 + 1 + iv.size + ciphertext.size) out.put(MAGIC.toByteArray(Charsets.US_ASCII)) out.put(VERSION.toByte()) @@ -52,7 +73,7 @@ object AesGcmVaultEnvelope { return out.array() } - fun decrypt(key: SecretKey, envelope: ByteArray): ByteArray { + fun parse(envelope: ByteArray): Parsed { if (envelope.size > MAX_ENVELOPE) { throw VaultException(VaultHealth.CORRUPT, "Envelope too large") } @@ -77,21 +98,50 @@ object AesGcmVaultEnvelope { buf.get(iv) val ciphertext = ByteArray(buf.remaining()) buf.get(ciphertext) + return Parsed(iv = iv, ciphertext = ciphertext) + } + + /** + * JVM / software-key encryption: generates IV via [random] and supplies it to Cipher. + * **Not** for Android Keystore keys with randomized encryption required. + */ + fun encryptWithCallerIv( + key: SecretKey, + plaintext: ByteArray, + random: SecureRandom = SecureRandom() + ): ByteArray { + val iv = ByteArray(IV_LENGTH) + random.nextBytes(iv) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) + val ciphertext = cipher.doFinal(plaintext) + return pack(iv, ciphertext) + } + + /** + * @deprecated Use [encryptWithCallerIv] for JVM keys or Keystore-generated IV path. + * Kept as a named alias so existing unit tests keep compiling until updated. + */ + fun encrypt(key: SecretKey, plaintext: ByteArray, random: SecureRandom = SecureRandom()): ByteArray = + encryptWithCallerIv(key, plaintext, random) + + fun decrypt(key: SecretKey, envelope: ByteArray): ByteArray { + val parsed = parse(envelope) val cipher = try { Cipher.getInstance("AES/GCM/NoPadding") } catch (e: Exception) { throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Cipher unavailable", e) } try { - cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, iv)) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(TAG_BITS, parsed.iv)) } catch (e: Exception) { - // Cipher.init failures are not authentication-tag failures and are not - // automatically KEY_INVALIDATED — let the Android Keystore layer classify. + // Cipher.init failures are not authentication-tag failures. throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Cipher init failed", e) } return try { cipher.updateAAD(AAD_STRING.toByteArray(Charsets.UTF_8)) - cipher.doFinal(ciphertext) + cipher.doFinal(parsed.ciphertext) } catch (e: javax.crypto.AEADBadTagException) { throw VaultException(VaultHealth.AUTH_TAG_FAILURE, "Vault authentication failed", e) } catch (e: Exception) { @@ -105,15 +155,21 @@ object AesGcmVaultEnvelope { throw VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Vault decrypt failed", e) } } + + /** Extract IV bytes from a packed envelope (for tests). */ + fun ivFromEnvelope(envelope: ByteArray): ByteArray = parse(envelope).iv } -/** Test/production cipher backed by an injected [SecretKey] (never hard-coded material). */ +/** + * Test/production cipher backed by an injected software [SecretKey] + * (never hard-coded material). Uses caller IV from [random] — suitable for JVM tests only. + */ class SecretKeyVaultCipher( private val key: SecretKey, private val random: SecureRandom = SecureRandom() ) : VaultCipher { override fun encrypt(plaintext: ByteArray): ByteArray = - AesGcmVaultEnvelope.encrypt(key, plaintext, random) + AesGcmVaultEnvelope.encryptWithCallerIv(key, plaintext, random) override fun decrypt(envelope: ByteArray): ByteArray = AesGcmVaultEnvelope.decrypt(key, envelope) diff --git a/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt b/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt index 247f5a6..890fcf6 100644 --- a/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt @@ -7,8 +7,10 @@ import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import java.security.SecureRandom +import javax.crypto.Cipher import javax.crypto.KeyGenerator import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec class AesGcmVaultEnvelopeTest { @@ -19,7 +21,7 @@ class AesGcmVaultEnvelopeTest { @Test fun roundTrip_succeeds() { val plain = "hello vault payload".toByteArray() - val env = AesGcmVaultEnvelope.encrypt(key, plain) + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) val out = AesGcmVaultEnvelope.decrypt(key, env) assertArrayEquals(plain, out) } @@ -27,32 +29,55 @@ class AesGcmVaultEnvelopeTest { @Test fun samePlaintext_encryptedTwice_differentCiphertext() { val plain = "same-secret-system-code-ABCD".toByteArray() - val a = AesGcmVaultEnvelope.encrypt(key, plain) - val b = AesGcmVaultEnvelope.encrypt(key, plain) + val a = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) + val b = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) assertFalse(a.contentEquals(b)) + assertFalse( + AesGcmVaultEnvelope.ivFromEnvelope(a) + .contentEquals(AesGcmVaultEnvelope.ivFromEnvelope(b)) + ) assertArrayEquals( AesGcmVaultEnvelope.decrypt(key, a), AesGcmVaultEnvelope.decrypt(key, b) ) } + @Test + fun pack_storesIvInEnvelope() { + val iv = ByteArray(12) { i -> (i + 1).toByte() } + val ciphertext = ByteArray(32) { 7 } + val env = AesGcmVaultEnvelope.pack(iv, ciphertext) + val parsed = AesGcmVaultEnvelope.parse(env) + assertArrayEquals(iv, parsed.iv) + assertArrayEquals(ciphertext, parsed.ciphertext) + } + + @Test + fun parse_rejectsInvalidIvLength() { + val good = AesGcmVaultEnvelope.encryptWithCallerIv(key, "x".toByteArray()) + good[5] = 8 // wrong ivLen + val ex = assertThrows(VaultException::class.java) { + AesGcmVaultEnvelope.parse(good) + } + assertEquals(VaultHealth.CORRUPT, ex.health) + } + @Test fun systemCodeAndPassword_doNotAppearInCiphertext() { val systemCode = "Z9-Y8-X7" val password = "SuperSecretPass99" val plain = "code=$systemCode;pass=$password".toByteArray() - val env = AesGcmVaultEnvelope.encrypt(key, plain) + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) val asString = String(env, Charsets.ISO_8859_1) assertFalse(asString.contains(systemCode)) assertFalse(asString.contains(password)) - // Also check raw bytes subsequence assertFalse(env.containsSubsequence(systemCode.toByteArray())) assertFalse(env.containsSubsequence(password.toByteArray())) } @Test fun modifiedCiphertext_failsAuthentication() { - val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "payload".toByteArray()).copyOf() env[env.size - 1] = (env[env.size - 1].toInt() xor 0xFF).toByte() val ex = assertThrows(VaultException::class.java) { AesGcmVaultEnvelope.decrypt(key, env) @@ -62,8 +87,7 @@ class AesGcmVaultEnvelopeTest { @Test fun modifiedIv_failsAuthentication() { - val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() - // IV starts at offset 6 (magic4 + ver1 + ivLen1) + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "payload".toByteArray()).copyOf() env[6] = (env[6].toInt() xor 0x01).toByte() val ex = assertThrows(VaultException::class.java) { AesGcmVaultEnvelope.decrypt(key, env) @@ -73,12 +97,8 @@ class AesGcmVaultEnvelopeTest { @Test fun wrongAadVersion_failsAuthentication() { - // Encrypt with correct AAD, then decrypt with a cipher that uses wrong AAD by - // forging an otherwise-valid envelope is already covered by tag failure when - // ciphertext is authentic for different AAD — re-encrypt path: val plain = "data".toByteArray() - val env = AesGcmVaultEnvelope.encrypt(key, plain) - // Tamper magic/version path: wrong version + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) env[4] = 9 val ex = assertThrows(VaultException::class.java) { AesGcmVaultEnvelope.decrypt(key, env) @@ -88,7 +108,7 @@ class AesGcmVaultEnvelopeTest { @Test fun truncatedEnvelope_failsSafely() { - val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()) + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "payload".toByteArray()) val truncated = env.copyOf(10) val ex = assertThrows(VaultException::class.java) { AesGcmVaultEnvelope.decrypt(key, truncated) @@ -98,7 +118,7 @@ class AesGcmVaultEnvelopeTest { @Test fun invalidMagic_failsSafely() { - val env = AesGcmVaultEnvelope.encrypt(key, "payload".toByteArray()).copyOf() + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "payload".toByteArray()).copyOf() env[0] = 'X'.code.toByte() val ex = assertThrows(VaultException::class.java) { AesGcmVaultEnvelope.decrypt(key, env) @@ -111,9 +131,8 @@ class AesGcmVaultEnvelopeTest { val plain = "x".toByteArray() val ivs = HashSet() repeat(64) { - val env = AesGcmVaultEnvelope.encrypt(key, plain) - // IV is 12 bytes starting at offset 6 - val iv = env.copyOfRange(6, 18) + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) + val iv = AesGcmVaultEnvelope.ivFromEnvelope(env) assertTrue(ivs.add(iv.toHex())) } } @@ -126,14 +145,66 @@ class AesGcmVaultEnvelopeTest { } @Test - fun deterministicRandom_stillChangesIvWhenSeededDifferently() { - // Prove encrypt always pulls fresh IV bytes from SecureRandom - val r1 = SecureRandom() - val r2 = SecureRandom() - val a = AesGcmVaultEnvelope.encrypt(key, "p".toByteArray(), r1) - val b = AesGcmVaultEnvelope.encrypt(key, "p".toByteArray(), r2) - // Extremely unlikely to collide - assertFalse(a.contentEquals(b)) + fun jvmCallerIvPath_usesInjectedRandom() { + // Deterministic only when an injected SecureRandom controls the IV stream. + val fixed = object : SecureRandom() { + private var n = 0 + override fun nextBytes(bytes: ByteArray) { + for (i in bytes.indices) { + bytes[i] = (n++ and 0xFF).toByte() + } + } + } + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "p".toByteArray(), fixed) + val iv = AesGcmVaultEnvelope.ivFromEnvelope(env) + assertEquals(12, iv.size) + // First 12 sequential bytes from the fixed random + assertEquals(0.toByte(), iv[0]) + assertEquals(11.toByte(), iv[11]) + } + + @Test + fun softwareKey_encryptInitWithCallerIv_isAllowed() { + // Documents JVM behavior: software AES keys accept GCMParameterSpec on encrypt. + // Android Keystore with randomized encryption required rejects this path. + val iv = ByteArray(12) { 3 } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv)) + cipher.updateAAD(AesGcmVaultEnvelope.AAD_STRING.toByteArray(Charsets.UTF_8)) + val ct = cipher.doFinal(byteArrayOf(1)) + val env = AesGcmVaultEnvelope.pack(iv, ct) + assertArrayEquals(byteArrayOf(1), AesGcmVaultEnvelope.decrypt(key, env)) + } + + @Test + fun keystoreStyleEncrypt_noCallerIv_thenPackGeneratedIv() { + // Mirrors Android Keystore encrypt shape on a software key: + // init(ENCRYPT_MODE, key) without GCMParameterSpec → cipher.iv → pack. + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key) + cipher.updateAAD(AesGcmVaultEnvelope.AAD_STRING.toByteArray(Charsets.UTF_8)) + val ct = cipher.doFinal("keystore-style".toByteArray()) + val iv = cipher.iv + assertTrue(iv != null && iv.size == AesGcmVaultEnvelope.IV_LENGTH) + val env = AesGcmVaultEnvelope.pack(iv!!, ct) + assertArrayEquals( + "keystore-style".toByteArray(), + AesGcmVaultEnvelope.decrypt(key, env) + ) + // No caller GCMParameterSpec was used on encrypt; IV came from cipher.iv + assertArrayEquals(iv, AesGcmVaultEnvelope.ivFromEnvelope(env)) + } + + @Test + fun decrypt_suppliesStoredIvFromEnvelope() { + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, "stored-iv".toByteArray()) + val parsed = AesGcmVaultEnvelope.parse(env) + assertEquals(AesGcmVaultEnvelope.IV_LENGTH, parsed.iv.size) + // Decrypt path uses parse + GCMParameterSpec(stored iv) + assertArrayEquals( + "stored-iv".toByteArray(), + AesGcmVaultEnvelope.decrypt(key, env) + ) } private fun ByteArray.containsSubsequence(needle: ByteArray): Boolean { diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md index 1a23f0e..b744cf3 100644 --- a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -112,6 +112,28 @@ AAD: "WestCurrentSavedPools:v1" Random IV per write; never reused by the writer. Max envelope 600_000 bytes (checked before full allocation on read). +### Android Keystore IV generation (device fix) + +Keys use `setRandomizedEncryptionRequired(true)`. Android Keystore **rejects** +caller-provided IVs on encrypt (`cipher.init(ENCRYPT_MODE, key, GCMParameterSpec(...))`). + +Correct production path (`AndroidKeystoreVaultCipher`): + +1. `Cipher.getInstance("AES/GCM/NoPadding")` +2. `cipher.init(Cipher.ENCRYPT_MODE, key)` — **no** caller IV +3. Apply AAD +4. `doFinal(plaintext)` +5. `val iv = cipher.iv` — validate non-null and 12 bytes +6. Pack IV + ciphertext into the WCAE envelope via `AesGcmVaultEnvelope.pack` + +Decrypt continues to supply the **stored** IV through `GCMParameterSpec`. + +JVM/unit tests use `SecretKeyVaultCipher` / `encryptWithCallerIv` (software AES allows +caller IVs). Envelope pack/parse is platform-neutral and shared. + +Physical failure before this fix: remote connect succeeded with Save enabled, but the +app showed “Connected, but the pool could not be saved securely on this phone.” + --- ## 5. Secure storage path From 5b0c4b91768aee25586dbb1bebca0fe0eb705597 Mon Sep 17 00:00:00 2001 From: "8 West Ventures, LLC" Date: Mon, 13 Jul 2026 00:32:05 -0700 Subject: [PATCH 6/6] Document Phase 1.6 secure-save physical validation --- .../PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md index b744cf3..c694013 100644 --- a/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -134,6 +134,10 @@ caller IVs). Envelope pack/parse is platform-neutral and shared. Physical failure before this fix: remote connect succeeded with Save enabled, but the app showed “Connected, but the pool could not be saved securely on this phone.” +**Physical retest (2026-07-13):** After commit `4d463e6`, Frankie confirmed on a real +Android phone that secure save of a remote pool **succeeded** (Keystore-generated IV +path). See §16 for passed vs pending detail. + --- ## 5. Secure storage path @@ -306,7 +310,49 @@ Existing Phase 1.x tests remain green. ## 16. Physical verification checklist (Frankie) -Deferred until 1.6.1 passes review. APK: `%TEMP%\westcurrent-app-build\outputs\apk\debug\app-debug.apk` +APK used for validation: `%TEMP%\westcurrent-app-build\outputs\apk\debug\app-debug.apk` +**Physically tested code commit:** `4d463e6bb59e3e3032c1b792b6d6330c69795e62` + +### 16.1 Physical validation log — 2026-07-13 (partial pass) + +Frankie installed the debug APK built from `4d463e6` on his real Android phone and +exercised remote ScreenLogic connect + secure save against a real controller. + +#### Passed (explicitly confirmed) + +| Check | Result | +|-------|--------| +| Remote ScreenLogic connection | Succeeded | +| “Save this pool on this phone” enabled | Yes | +| “Remember password securely” | Left **off** for this first pass | +| Android Keystore key creation | Succeeded (implied by vault write) | +| Keystore-generated AES-GCM encryption IV | Succeeded (secure save completed) | +| Encrypted vault write | Succeeded | +| Prior error *“Connected, but the pool could not be saved securely on this phone”* | **Did not appear** | +| Saved remote pool created on the phone | Succeeded | +| Plaintext or software-encryption fallback | **Not used** (fail-closed Keystore path only) | +| Pool / spa / jets / light / heat / temperature or other equipment commands | **None sent** | + +The Keystore-generated-IV correction therefore **passed real-device validation** for the +secure-save path only. + +#### Still pending (not marked complete) + +Do **not** treat these as passed unless confirmed separately: + +- Force-stop and reopen persistence +- Recent Pools after restart +- Password-only reconnect +- Remembered-password reconnect +- Rename persistence +- Forget-password behavior +- Removal persistence +- Local Wi-Fi connection and DHCP / “Find on Wi-Fi” recovery +- Full Android instrumentation suite on device/emulator +- Remaining pre-merge correctness cleanup + +**Status:** Physical validation is **partially passed**, not fully complete. +PR #3 remains **draft and unmerged**. ---