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..88993d2 --- /dev/null +++ b/app/src/androidTest/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipherInstrumentedTest.kt @@ -0,0 +1,118 @@ +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.assertNotNull +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). + * + * Exercises the corrected path: encrypt init without caller IV, + * Keystore-generated IV packed into WCAE envelope, decrypt with stored IV. + */ +@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_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)) + 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 { + 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) + } + } + + @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/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 +95,31 @@ 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, epoch, reason -> + vm.submitSavedPoolPassword(id, pass, remember, epoch, reason) + }, + 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=${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 new file mode 100644 index 0000000..639bdc6 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/AndroidKeystoreVaultCipher.kt @@ -0,0 +1,238 @@ +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.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] + * 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). + */ +class AndroidKeystoreVaultCipher( + private val keyAlias: String = KEY_ALIAS +) : VaultCipher { + + override fun encrypt(plaintext: ByteArray): ByteArray { + return try { + val key = getOrCreateKey() + encryptWithKeystoreGeneratedIv(key, plaintext) + } catch (e: VaultException) { + throw e + } catch (e: Exception) { + throw mapKeyOrCryptoFailure(e, encrypting = true) + } + } + + override fun decrypt(envelope: ByteArray): ByteArray { + 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 + } catch (e: Exception) { + throw mapKeyOrCryptoFailure(e, encrypting = false) + } + } + + /** + * 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. + */ + fun deleteKey(): KeyDeleteResult { + return try { + val ks = KeyStore.getInstance(ANDROID_KEYSTORE) + ks.load(null) + if (!ks.containsAlias(keyAlias)) { + return KeyDeleteResult.AlreadyAbsent + } + ks.deleteEntry(keyAlias) + ks.load(null) + if (ks.containsAlias(keyAlias)) { + KeyDeleteResult.Failed( + 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.LOCKED_OR_UNAVAILABLE, + "Keystore key deletion failed" + ) + } + } + + fun keyExists(): Boolean { + return try { + getExistingKey() != null + } catch (_: Exception) { + false + } + } + + /** + * Diagnostic security level from [KeyInfo] when available. + * Never claims StrongBox unless verified via KeyInfo. + */ + fun securityLevelLabel(): String { + return try { + 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" + } + } + + private fun getOrCreateKey(): SecretKey { + 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() + } + + private fun generateKey(): SecretKey { + val keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, + ANDROID_KEYSTORE + ) + val spec = 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) + .build() + keyGenerator.init(spec) + 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 + } + + /** + * 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 { + if (e is VaultException) return e + val msg = e.message.orEmpty() + return when { + e is UnrecoverableKeyException || + 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("not initialized", ignoreCase = true) && + msg.contains("KeyStore", ignoreCase = true)) -> + VaultException(VaultHealth.LOCKED_OR_UNAVAILABLE, "Keystore unavailable", e) + !encrypting && ( + 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( + VaultHealth.LOCKED_OR_UNAVAILABLE, + if (encrypting) "Vault encryption failed" else "Vault decryption 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 new file mode 100644 index 0000000..ade8510 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/ConnectSaveOptions.kt @@ -0,0 +1,82 @@ +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)" +} + +/** + * 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. + */ +class SavedPoolPasswordPrompt( + val profileId: SavedPoolId, + val displayName: String, + val routeKind: SavedRouteKind, + 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, " + + "reason=$reason, epoch=$requestEpoch)" +} + +/** + * 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)" + } +} + +/** + * 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 new file mode 100644 index 0000000..c8306c7 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolCodec.kt @@ -0,0 +1,217 @@ +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. + * + * 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 + 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 = 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() + } + } + + 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 isPreferred = inp.readBoolean() + 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, + isPreferred = isPreferred + ) + } + if (inp.available() != 0) { + 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) + 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 { + val len = inp.readInt() + if (len < 0 || len > MAX_STRING) { + throw VaultException(VaultHealth.CORRUPT, "Invalid string length") + } + val raw = ByteArray(len) + 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 new file mode 100644 index 0000000..3c11c65 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolModels.kt @@ -0,0 +1,271 @@ +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, + /** Explicit user-preferred recent entry — not derived from last-success time. */ + val isPreferred: Boolean, + /** Preferred route for reconnect; non-secret. */ + val preferredRouteKind: SavedRouteKind, + /** + * Preferred-route password status (legacy convenience for Recent Pools). + * Settings should use [remotePasswordStatus] / [localPasswordStatus]. + */ + 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, remote=$remotePasswordStatus, " + + "local=$localPasswordStatus, 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. Prefer [copy] for copy-on-write mutations. + */ +class SavedPoolProfile( + val id: SavedPoolId, + val displayName: String, + val adapterId: String, + val controllerIdentity: String?, + val createdAtMs: Long, + 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 + } + + fun readyToConnect(kind: SavedRouteKind = preferredRoute): Boolean { + val route = routes.find { it.kind == kind } ?: routes.firstOrNull() ?: return false + 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}, 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]. + */ +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 + abstract val blankPasswordSucceeded: Boolean + + class ScreenLogicRemote( + val systemCode: String, + val password: String?, + override val passwordRequired: Boolean, + override 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, + override 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 passwordDecision: PasswordSaveDecision, + val timestampMs: Long = System.currentTimeMillis() +) { + override fun toString(): String = + "SavedPoolDraft(name=$displayName, adapter=$adapterId, route=${route.kind}, " + + "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 { + class Ready( + val profileId: SavedPoolId, + val displayName: String, + /** Transient — caller must not put this in StateFlow. */ + 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=***, route=$routeKind)" + } + + class PasswordRequired( + val profileId: SavedPoolId, + val displayName: String, + 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() + 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)" +} + +/** 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 new file mode 100644 index 0000000..4b71b4c --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepository.kt @@ -0,0 +1,725 @@ +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 + +/** + * 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 + suspend fun loadConnection( + profileId: SavedPoolId, + passwordOverride: String? = null, + preferredKind: SavedRouteKind? = null + ): SavedPoolConnectionResult + + suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? + + 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 applyPasswordDecision( + profileId: SavedPoolId, + kind: SavedRouteKind, + password: String?, + decision: PasswordSaveDecision + ) + /** Explicit preferred flag — does not alter lastSuccessAtMs. */ + suspend fun setPreferred(profileId: SavedPoolId, preferred: Boolean = true) + suspend fun updateLocalAddress( + profileId: SavedPoolId, + address: String, + port: Int, + gatewayLabel: String, + controllerIdentity: String? + ) + suspend fun resetVault(): VaultResetResult +} + +class DefaultSavedPoolRepository( + private val cipher: VaultCipher, + private val store: VaultFileStore, + private val preferences: AppPreferencesStore? = null, + private val maxProfiles: Int = 40, + private val onKeyReset: (() -> KeyDeleteResult)? = null +) : SavedPoolRepository { + + private val mutex = Mutex() + /** 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 { + withProfiles { profiles -> + lastHealth = if (profiles.isEmpty()) VaultHealth.EMPTY else VaultHealth.AVAILABLE + lastHealth + } + } catch (e: VaultException) { + lastHealth = e.health + e.health + } + } + + override suspend fun list(): List = mutex.withLock { + try { + 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() + else throw e + } + } + + override suspend fun loadConnection( + profileId: SavedPoolId, + passwordOverride: String?, + preferredKind: SavedRouteKind? + ): SavedPoolConnectionResult = mutex.withLock { + val profiles = try { + withProfiles { it } + } catch (e: VaultException) { + lastHealth = e.health + 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") + 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 + ) + } + SavedPoolConnectionResult.Ready( + profileId = profileId, + displayName = profile.displayName, + connectionTarget = ScreenLogicRemoteTarget( + systemCode = route.systemCode, + password = password.orEmpty() + ), + expectedControllerIdentity = expectedId, + routeKind = route.kind, + usedStoredPassword = usedStored + ) + } + 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 + ) + } + val gw = DiscoveredGateway( + id = "saved-local", + displayName = route.gatewayLabel.ifBlank { "Saved local pool" }, + address = route.address, + port = route.port + ) + SavedPoolConnectionResult.Ready( + profileId = profileId, + displayName = profile.displayName, + connectionTarget = ScreenLogicLocalTarget(gw, password.orEmpty()), + expectedControllerIdentity = expectedId ?: route.controllerIdentity, + routeKind = route.kind, + usedStoredPassword = usedStored + ) + } + } + } + + override suspend fun expectedControllerIdentity(profileId: SavedPoolId): String? = + mutex.withLock { + try { + withProfiles { it.find { p -> p.id == profileId }?.controllerIdentity } + } catch (_: VaultException) { + null + } + } + + override suspend fun saveAfterSuccessfulConnection(draft: SavedPoolDraft): SaveProfileResult = + mutex.withLock { + val profiles = try { + withProfiles { it }.toMutableList() + } catch (e: VaultException) { + if (e.health == VaultHealth.EMPTY) mutableListOf() + else { + lastHealth = e.health + return SaveProfileResult.VaultError(e.health) + } + } + + 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()) + } + + if (profiles.size >= maxProfiles) { + return SaveProfileResult.ProfileLimitReached(maxProfiles) + } + + val route = applyPasswordDecisionToRoute( + newRoute = draft.route, + existing = null, + decision = when (draft.passwordDecision) { + PasswordSaveDecision.PRESERVE_EXISTING -> + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + else -> draft.passwordDecision + } + ) + 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) + } + 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 = 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 = 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 { + withProfiles { it }.toMutableList() + } catch (e: VaultException) { + if (e.health == VaultHealth.EMPTY) return@withLock + throw e + } + val removed = profiles.find { it.id == profileId } ?: return@withLock + profiles.removeAll { it.id == profileId } + if (profiles.isEmpty()) { + store.delete() + lastHealth = VaultHealth.EMPTY + } else { + persistProfiles(profiles) + } + // Clear controller temp unit only if no remaining profile shares the identity. + val identity = removed.controllerIdentity + if (identity != null && preferences != null) { + val stillReferenced = profiles.any { it.controllerIdentity == identity } + if (!stillReferenced) { + ControllerTempUnitStore.clear(preferences, identity) + } + } + } + + override suspend fun applyPasswordDecision( + profileId: SavedPoolId, + kind: SavedRouteKind, + password: String?, + decision: PasswordSaveDecision + ) = mutex.withLock { + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } + if (idx < 0) return@withLock + 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 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, + address: String, + port: Int, + gatewayLabel: String, + controllerIdentity: String? + ) = mutex.withLock { + val profiles = withProfiles { it }.toMutableList() + val idx = profiles.indexOfFirst { it.id == profileId } + if (idx < 0) return@withLock + 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 + } + // 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, + password = existing.password, + passwordRequired = existing.passwordRequired, + blankPasswordSucceeded = existing.blankPasswordSucceeded, + controllerIdentity = controllerIdentity ?: existing.controllerIdentity + ) + profiles[idx] = p.copy( + routes = newRoutes, + controllerIdentity = controllerIdentity ?: p.controllerIdentity + ) + persistProfiles(profiles) + } + + override suspend fun resetVault(): VaultResetResult = mutex.withLock { + try { + // 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") + } + val keyResult = try { + onKeyReset?.invoke() ?: KeyDeleteResult.AlreadyAbsent + } catch (e: Exception) { + lastHealth = VaultHealth.IO_FAILURE + return VaultResetResult.PartialFailure( + VaultHealth.IO_FAILURE, + "Keystore key removal failed" + ) + } + 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 + } + + // ——— internals ——— + + /** + * Decrypt → operate → zero plaintext. Never caches decrypted profiles. + * JVM String fields cannot be zeroed; minimize scope. + */ + private inline fun withProfiles(block: (List) -> T): T { + // 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()) + } + 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) + envelope.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) + } + } + + /** + * 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 { + if (identity != null) { + val byId = profiles.indexOfFirst { + it.controllerIdentity == identity && it.adapterId == draft.adapterId + } + if (byId >= 0) return byId + // 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 + 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) + } + } + + 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 + } + } + } + } + + 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 { + val preferred = routes.find { it.kind == preferredRoute } ?: routes.firstOrNull() + val preferredKind = preferred?.kind ?: preferredRoute + val kindLabel = when (preferredKind) { + SavedRouteKind.SCREENLOGIC_REMOTE -> "Remote" + SavedRouteKind.SCREENLOGIC_LOCAL -> "Home Wi‑Fi" + } + val hasLocal = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_LOCAL } + val hasRemote = routes.any { it.kind == SavedRouteKind.SCREENLOGIC_REMOTE } + return SavedPoolSummary( + id = id, + displayName = displayName, + adapterId = adapterId, + routeKindLabel = kindLabel, + lastSuccessAtMs = lastSuccessAtMs, + passwordRequired = passwordRequiredForConnect(), + readyToConnect = readyToConnect(), + hasLocalRoute = hasLocal, + hasRemoteRoute = hasRemote, + isPreferred = isPreferred, + preferredRouteKind = preferredKind, + passwordStatus = passwordStatus(preferredKind), + remotePasswordStatus = if (hasRemote) { + passwordStatus(SavedRouteKind.SCREENLOGIC_REMOTE) + } else null, + localPasswordStatus = if (hasLocal) { + passwordStatus(SavedRouteKind.SCREENLOGIC_LOCAL) + } else null + ) +} + +object SavedPoolDraftFactory { + fun fromRemote( + systemCode: String, + password: String, + displayName: String, + controllerIdentity: String?, + passwordDecision: PasswordSaveDecision, + 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(), + password = if (blank) null else password, + passwordRequired = !blank, + blankPasswordSucceeded = blank + ), + preferredRoute = SavedRouteKind.SCREENLOGIC_REMOTE, + passwordDecision = passwordDecision, + timestampMs = timestampMs + ) + } + + fun fromLocal( + gateway: DiscoveredGateway, + password: String, + controllerIdentity: String?, + passwordDecision: PasswordSaveDecision, + 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, + blankPasswordSucceeded = blank, + controllerIdentity = controllerIdentity + ), + preferredRoute = SavedRouteKind.SCREENLOGIC_LOCAL, + passwordDecision = passwordDecision, + timestampMs = timestampMs + ) + } + + /** + * 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 { + 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/SavedPoolRepositoryFactory.kt b/app/src/main/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryFactory.kt new file mode 100644 index 0000000..00feef3 --- /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] / [SimulatedAtomicVaultFileStore.VAULT_FILE_NAME] + * Store: [AndroidAtomicVaultFileStore] (platform AtomicFile) + * 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 = AndroidAtomicVaultFileStore(appContext.noBackupFilesDir) + val repository = DefaultSavedPoolRepository( + cipher = cipher, + store = store, + preferences = preferences, + onKeyReset = { cipher.deleteKey() } + ) + return ProductionSavedPools( + repository = repository, + cipher = cipher, + store = store + ) + } + + suspend fun onIo(block: suspend () -> T): T = + withContext(Dispatchers.IO) { block() } +} + +class ProductionSavedPools( + val repository: SavedPoolRepository, + val cipher: AndroidKeystoreVaultCipher, + val store: AndroidAtomicVaultFileStore +) { + fun diagnosticsVaultPathHint(): String = + "noBackupFilesDir/${SimulatedAtomicVaultFileStore.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..27bfa37 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultCipher.kt @@ -0,0 +1,176 @@ +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. + * + * **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 + fun decrypt(envelope: ByteArray): ByteArray +} + +/** + * Versioned AES-GCM envelope (platform-neutral packer/parser only). + * + * ``` + * magic[4] = "WCAE" + * version u8 = 1 + * ivLen u8 + * iv[ivLen] + * ciphertext+tag[...] + * ``` + * + * 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" + 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 + + 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()) + out.put(iv.size.toByte()) + out.put(iv) + out.put(ciphertext) + return out.array() + } + + fun parse(envelope: ByteArray): Parsed { + 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 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, parsed.iv)) + } catch (e: Exception) { + // 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(parsed.ciphertext) + } 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) + } + } + + /** Extract IV bytes from a packed envelope (for tests). */ + fun ivFromEnvelope(envelope: ByteArray): ByteArray = parse(envelope).iv +} + +/** + * 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.encryptWithCallerIv(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..68a978e --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/savedpools/VaultFileStore.kt @@ -0,0 +1,358 @@ +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 + +/** + * 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. */ +class InMemoryVaultFileStore : VaultFileStore { + @Volatile + private var bytes: ByteArray? = null + + 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() + } + + override fun delete() { + bytes = null + } + + 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 + } +} + +/** + * Pure-Kotlin simulation of Android [android.util.AtomicFile] for JVM tests. + * + * 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 SimulatedAtomicVaultFileStore( + private val directory: File, + private val fileName: String = VAULT_FILE_NAME, + private val fault: FaultInjection = FaultInjection() +) : VaultFileStore { + + 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? { + // 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 { + 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") + } + 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) { + } + } + 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) + } + } + + 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)) + } + + /** + * 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? { + return try { + atomicFile.openRead().use { input -> + // 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 + total += n + if (total > InMemoryVaultFileStore.MAX_ENVELOPE_BYTES) { + throw VaultException(VaultHealth.CORRUPT, "Vault exceeds size limit") + } + buf.write(chunk, 0, n) + } + val out = buf.toByteArray() + if (out.isEmpty()) { + throw VaultException(VaultHealth.CORRUPT, "Zero-byte vault") + } + out + } + } catch (e: java.io.FileNotFoundException) { + null // no committed 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") + } + 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) + } + } + + override fun delete() { + try { + 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) + } + } + + fun filePathForDiagnostics(): String = atomicFile.baseFile.absolutePath + fun directoryForDiagnostics(): String = directory.absolutePath +} + +/** 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() +} + +/** @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..0489e82 --- /dev/null +++ b/app/src/main/java/com/pentaircontrol/pool/session/ConnectionAttemptResult.kt @@ -0,0 +1,67 @@ +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 and was never installed. + * [code] is a stable non-secret classifier for ViewModel branching. + */ + class Rejected( + override val generation: Int, + val reason: String, + val code: RejectCode = RejectCode.IDENTITY_MISMATCH + ) : ConnectionAttemptResult() { + 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() { + 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 f9af91d..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,36 +193,65 @@ 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() } } - 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 +259,81 @@ 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) + /** + * 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, + 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,15 +348,15 @@ 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 _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() @@ -290,25 +364,69 @@ 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) + + val identityCheck = validateCandidateIdentity( + expected = expectedControllerIdentity, + candidate = candidateIdentity + ) + if (identityCheck != null) { 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(identityCheck.userMessage) + _lastError.value = identityCheck.reason + return ConnectionAttemptResult.Rejected( + generation = gen, + reason = identityCheck.reason, + code = identityCheck.code + ) + } + + // 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 +435,11 @@ class SiteSession( ) } _lastError.value = e.message + return ConnectionAttemptResult.Failed( + generation = gen, + message = e.message ?: "Connection failed", + authenticationRequired = auth + ) } } @@ -398,6 +521,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() @@ -717,8 +847,18 @@ 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) + 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) return false } @@ -757,79 +897,107 @@ 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() + + private data class IdentityReject( + val code: ConnectionAttemptResult.RejectCode, + val reason: String, + val userMessage: String + ) + /** - * 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. + * 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 reconcileEffectiveTemperatureUnit( + 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. + */ + 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) @@ -837,15 +1005,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) { @@ -856,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 @@ -886,6 +1096,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..26d154c 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,11 @@ 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.LocalRecoveryState +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 +102,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 +119,28 @@ 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, Int, com.pentaircontrol.pool.savedpools.PasswordPromptReason + ) -> 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, + localRecovery: LocalRecoveryState = LocalRecoveryState.Idle, + onStartLocalRecovery: (SavedPoolId) -> Unit = {}, + onConnectRecoveredGateway: (SavedPoolId, DiscoveredGateway, String, Boolean) -> Unit = + { _, _, _, _ -> }, + onDismissLocalRecovery: () -> Unit = {} ) { val showMainShell = connection.isLiveConnected || connection is ConnectionStatus.StaleOrDegraded || @@ -141,7 +167,20 @@ 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, + localRecovery = localRecovery, + onStartLocalRecovery = onStartLocalRecovery, + onConnectRecoveredGateway = onConnectRecoveredGateway, + onDismissLocalRecovery = onDismissLocalRecovery ) return } @@ -310,7 +349,15 @@ fun PoolApp( onForgetSavedUnit = onForgetSavedTemperatureUnit, savedUnit = savedTemperatureUnit, canRememberUnit = canRememberTemperatureUnit, - receipts = commandReceipts + receipts = commandReceipts, + savedPoolsState = savedPoolsState, + onRenameSavedPool = onRenameSavedPool, + 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 6d907e3..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,6 +4,8 @@ import android.app.Application import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope +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 +17,23 @@ 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.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 +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.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 @@ -24,29 +43,40 @@ 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.Job 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 +import java.util.concurrent.atomic.AtomicInteger /** - * UI ViewModel — domain types only. No protocol models or packet codes. + * UI ViewModel — domain types only. * - * [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). + * ## 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. * - * Do not use GlobalScope or an application-lifetime free-floating scope. + * 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(), + private val savedPools: SavedPoolRepository? = null, sessionFactory: (CoroutineScope) -> SiteSession ) : ViewModel() { 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 var localRecoveryJob: Job? = null + private val _appearance = MutableStateFlow( AppearanceMode.fromStorage(preferences.getString(AppPreferencesStore.KEY_APPEARANCE)) ) @@ -79,50 +109,572 @@ 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 - /** Test hook: inspect the bound session. */ + private val _savedPoolsState = + MutableStateFlow(SavedPoolsListState.Loading) + val savedPoolsState: StateFlow = _savedPoolsState.asStateFlow() + + private val _passwordPrompt = MutableStateFlow(null) + val passwordPrompt: StateFlow = _passwordPrompt.asStateFlow() + + private val _activeSavedProfileId = MutableStateFlow(null) + val activeSavedProfileId: StateFlow = _activeSavedProfileId.asStateFlow() + + private val _localRecovery = MutableStateFlow(LocalRecoveryState.Idle) + val localRecovery: StateFlow = _localRecovery.asStateFlow() + + init { + refreshSavedPools() + } + 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() + localRecoveryJob?.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 + 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() + ) { + 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.decisionForConnection( + save.rememberPassword, password.isEmpty() + ) + ) + } + ) + } } - 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() + ) { + 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.decisionForConnection( + save.rememberPassword, password.isEmpty() + ) + ) + } + ) + } } fun connectSimulator() { - viewModelScope.launch { session.connectSimulator() } + val epoch = beginConnectionRequest() + activeConnectionJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + session.connectSimulator() + } + } + + fun connectSavedPool(profileId: SavedPoolId) { + val repo = savedPools ?: return + 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 + } + if (!isRequestLive(epoch)) return@launch + when (load) { + is SavedPoolConnectionResult.PasswordRequired -> { + if (!isRequestLive(epoch)) return@launch + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = load.profileId, + displayName = load.displayName, + routeKind = load.routeKind, + reason = if (load.replacingFailedStoredPassword) { + PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD + } else { + PasswordPromptReason.MISSING_PASSWORD + }, + requestEpoch = epoch + ) + } + is SavedPoolConnectionResult.Ready -> { + completeSavedConnect( + epoch = epoch, + repo = repo, + ready = load, + passwordOverride = null, + rememberPassword = false, + promptReason = PasswordPromptReason.MISSING_PASSWORD + ) + } + is SavedPoolConnectionResult.VaultUnavailable -> { + _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 -> { + // loadConnection failures are vault/route prep, not live reachability. + _statusMessage.value = load.message + } + is SavedPoolConnectionResult.IdentityMismatch -> { + _statusMessage.value = load.message + } + } + } + } + + fun submitSavedPoolPassword( + profileId: SavedPoolId, + password: String, + rememberPassword: Boolean = false, + promptEpoch: Int? = null, + reason: PasswordPromptReason = PasswordPromptReason.MISSING_PASSWORD + ) { + val repo = savedPools ?: return + // Stale password prompt for an older request must not connect. + if (promptEpoch != null && promptEpoch != connectionRequestEpoch.get()) { + _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 + _passwordPrompt.value = null + 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 + } + if (!isRequestLive(epoch)) return@launch + when (load) { + is SavedPoolConnectionResult.Ready -> { + completeSavedConnect( + epoch = epoch, + repo = repo, + ready = load, + passwordOverride = password, + rememberPassword = rememberPassword, + promptReason = promptReason + ) + } + is SavedPoolConnectionResult.PasswordRequired -> { + if (!isRequestLive(epoch)) return@launch + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = load.profileId, + displayName = load.displayName, + routeKind = load.routeKind, + reason = promptReason, + requestEpoch = epoch + ) + } + else -> { + if (isRequestLive(epoch)) { + _statusMessage.value = "Couldn't prepare that saved pool." + } + } + } + } + } + + fun dismissPasswordPrompt() { + _passwordPrompt.value = 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" + } + 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." + } + } + } + + fun markPreferredSavedPool(profileId: SavedPoolId) { + val repo = savedPools ?: return + viewModelScope.launch { + try { + 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." + } + } + } + + 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, + passwordDecision = SavedPoolDraftFactory.decisionForConnection( + rememberPassword, target.password.isEmpty() + ) + ) + is ScreenLogicLocalTarget -> SavedPoolDraftFactory.fromLocal( + gateway = target.gateway, + password = target.password, + controllerIdentity = identity, + passwordDecision = SavedPoolDraftFactory.decisionForConnection( + rememberPassword, target.password.isEmpty() + ) + ) + else -> { + _statusMessage.value = "This connection type can't be saved yet." + return@launch + } + } + 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." + } + } + } + + 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() + localRecoveryJob?.cancel() + localRecoveryJob = viewModelScope.launch { + if (!isRequestLive(epoch)) return@launch + _localRecovery.value = LocalRecoveryState.Discovering(profileId) + val found = try { + // Quiet discovery: do not publish global Connecting and clobber other work. + session.discoverLocalQuiet() + } catch (_: Exception) { + emptyList() + } + // 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 + ) { + return@launch + } + _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 = 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, + "Couldn't confirm this is the same pool. Try another device or cancel." + ) + } + is ConnectionAttemptResult.Failed -> { + if (result.authenticationRequired) { + _passwordPrompt.value = SavedPoolPasswordPrompt( + profileId = profileId, + displayName = gateway.displayName, + routeKind = SavedRouteKind.SCREENLOGIC_LOCAL, + reason = PasswordPromptReason.REPLACE_REJECTED_STORED_PASSWORD, + requestEpoch = epoch + ) + } else { + _statusMessage.value = "Couldn't connect to the selected device." + } + } + is ConnectionAttemptResult.Superseded, + is ConnectionAttemptResult.Closed -> Unit + } + } + } + + fun dismissLocalRecovery() { + localRecoveryJob?.cancel() + localRecoveryJob = null + _localRecovery.value = LocalRecoveryState.Idle } fun disconnect() { - viewModelScope.launch { session.disconnect() } + beginConnectionRequest() // invalidate outstanding vault/connect work + viewModelScope.launch { + session.disconnect() + _activeSavedProfileId.value = null + _passwordPrompt.value = null + _localRecovery.value = LocalRecoveryState.Idle + } } - /** 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 +687,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 +697,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" @@ -155,17 +704,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) } } @@ -173,12 +719,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) } } @@ -187,17 +734,235 @@ 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)) + viewModelScope.launch { publishResult(session.sendLightProgram(programId, targetId)) } + } + + private suspend fun completeSavedConnect( + epoch: Int, + repo: SavedPoolRepository, + ready: SavedPoolConnectionResult.Ready, + passwordOverride: String?, + rememberPassword: Boolean, + promptReason: PasswordPromptReason + ) { + if (!isRequestLive(epoch)) return + val result = session.connectWithSessionFactory( + ready.connectionTarget, + 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) { + // 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 = passwordDecisionAfterPrompt( + promptReason = promptReason, + rememberPassword = rememberPassword, + passwordBlank = passwordOverride.isEmpty() + ) + if (decision != PasswordSaveDecision.PRESERVE_EXISTING) { + repo.applyPasswordDecision( + ready.profileId, + ready.routeKind, + passwordOverride, + decision + ) + } + } + } + refreshSavedPools() + } catch (_: Exception) { + } + _localRecovery.value = LocalRecoveryState.Idle + } + is ConnectionAttemptResult.Rejected -> { + 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 -> { + 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, + is ConnectionAttemptResult.Closed -> { + // Must not record success or disconnect another profile's session. + } + } + } + + /** + * 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, + 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." + } + } + } + } + is ConnectionAttemptResult.Failed, + is ConnectionAttemptResult.Rejected, + is ConnectionAttemptResult.Superseded, + is ConnectionAttemptResult.Closed -> { + // Must not save credentials for non-installed attempts. + } + } + } + + /** + * 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 = "Couldn't reach the saved home address on this network." + ) + } + + 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 -> @@ -210,10 +975,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 @@ -223,11 +984,12 @@ class PoolViewModel( } override fun onCleared() { + localRecoveryJob?.cancel() + beginConnectionRequest() session.close() super.onCleared() } - /** Test-only: invoke the same cleanup path as [onCleared]. */ @androidx.annotation.VisibleForTesting fun clearForTest() { onCleared() @@ -240,9 +1002,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..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 @@ -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,35 @@ 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, Int, com.pentaircontrol.pool.savedpools.PasswordPromptReason + ) -> Unit = { _, _, _, _, _ -> }, + onDismissPasswordPrompt: () -> Unit = {}, + onRenameSavedPool: (SavedPoolId, String) -> Unit = { _, _ -> }, + onDeleteSavedPool: (SavedPoolId) -> Unit = {}, + onRetryVault: () -> 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) } - 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 +191,37 @@ 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)) + } + } + + LocalRecoverySection( + state = localRecovery, + busy = busy, + onFind = onStartLocalRecovery, + onConnectGateway = onConnectRecoveredGateway, + onDismiss = onDismissLocalRecovery + ) + ConnectOptionCard( icon = Icons.Outlined.Search, title = "Find my pool", @@ -227,13 +292,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 +318,389 @@ fun ConnectScreen( onDismiss = { showAppearanceDialog = false } ) } + + passwordPrompt?.let { prompt -> + SavedPasswordDialog( + displayName = prompt.displayName, + replacing = prompt.isReplacement, + onSubmit = { password, remember -> + onSubmitSavedPassword( + prompt.profileId, password, remember, prompt.requestEpoch, prompt.reason + ) + }, + 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 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 +) { + 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( + 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( + 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 +818,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 +843,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 +870,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 +909,18 @@ private fun FindMyPoolSection( @Composable private fun RemoteSection( busy: Boolean, - onRemoteConnect: (String, String) -> Unit + 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)) { 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 ) @@ -446,9 +929,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 }, @@ -458,8 +949,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 +979,14 @@ 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") } + // 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) } Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Text( "Connect straight to a pool controller at a known address on your network.", @@ -508,10 +1018,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 +1049,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 +1126,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..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 @@ -46,6 +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 @@ -70,7 +75,15 @@ 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 = {}, + onForgetRememberedPassword: (SavedPoolId, SavedRouteKind) -> Unit = { _, _ -> }, + onResetVault: () -> Unit = {}, + vaultDiagnostics: String? = null ) { val hasSavedUnit = savedUnit != null Column( @@ -226,6 +239,18 @@ fun SettingsScreen( } } + // Saved pools + SavedPoolsSettingsCard( + connection = connection, + savedPoolsState = savedPoolsState, + onRename = onRenameSavedPool, + onDelete = onDeleteSavedPool, + onSaveCurrent = onSaveCurrentPool, + onMarkPreferred = onMarkPreferred, + onForgetPassword = onForgetRememberedPassword, + onResetVault = onResetVault + ) + // Connection val summary = summarizeConnection(connection, canRetry, hasLastKnownData = site != null) SettingsCard(title = "Connection") { @@ -276,7 +301,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 +342,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 +456,229 @@ fun SettingsScreen( } } +@Composable +private fun SavedPoolsSettingsCard( + connection: ConnectionStatus, + savedPoolsState: SavedPoolsListState, + onRename: (SavedPoolId, String) -> Unit, + onDelete: (SavedPoolId) -> Unit, + onSaveCurrent: () -> Unit, + onMarkPreferred: (SavedPoolId) -> Unit, + onForgetPassword: (SavedPoolId, SavedRouteKind) -> 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) }, + onForgetRemotePassword = { + onForgetPassword(pool.id, SavedRouteKind.SCREENLOGIC_REMOTE) + }, + onForgetLocalPassword = { + onForgetPassword(pool.id, SavedRouteKind.SCREENLOGIC_LOCAL) + } + ) + 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, + onForgetRemotePassword: () -> Unit = {}, + onForgetLocalPassword: () -> 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") + if (pool.isPreferred) append(" · preferred") + }, + style = MaterialTheme.typography.bodySmall, + 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.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/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..890fcf6 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/AesGcmVaultEnvelopeTest.kt @@ -0,0 +1,223 @@ +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.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +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.encryptWithCallerIv(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.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.encryptWithCallerIv(key, plain) + val asString = String(env, Charsets.ISO_8859_1) + assertFalse(asString.contains(systemCode)) + assertFalse(asString.contains(password)) + assertFalse(env.containsSubsequence(systemCode.toByteArray())) + assertFalse(env.containsSubsequence(password.toByteArray())) + } + + @Test + fun modifiedCiphertext_failsAuthentication() { + 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) + } + assertEquals(VaultHealth.AUTH_TAG_FAILURE, ex.health) + } + + @Test + fun modifiedIv_failsAuthentication() { + 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) + } + assertEquals(VaultHealth.AUTH_TAG_FAILURE, ex.health) + } + + @Test + fun wrongAadVersion_failsAuthentication() { + val plain = "data".toByteArray() + val env = AesGcmVaultEnvelope.encryptWithCallerIv(key, plain) + 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.encryptWithCallerIv(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.encryptWithCallerIv(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.encryptWithCallerIv(key, plain) + val iv = AesGcmVaultEnvelope.ivFromEnvelope(env) + 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 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 { + 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/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/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/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..23b7912 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolCodecTest.kt @@ -0,0 +1,175 @@ +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 +import org.junit.Test + +class SavedPoolCodecTest { + + @Test + fun version1_singleProfile_roundTrip() { + val original = listOf(sampleRemoteProfile()) + 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) + 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( + 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) + 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 = listOf( + SavedRoute.ScreenLogicRemote("CODE11", "secret", false, false), + SavedRoute.ScreenLogicLocal( + "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) + } + + @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())) + val bad = good.copyOf() + bad[4] = 0 + 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 ex = assertThrows(VaultException::class.java) { + SavedPoolCodec.decode(good.copyOf(good.size / 2)) + } + 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() { + 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) + } + 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) + assertFalse(remote.toString().contains("SECRET-CODE")) + assertFalse(remote.toString().contains("hunter2")) + } + + 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 = listOf( + 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 = listOf( + SavedRoute.ScreenLogicLocal( + "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 new file mode 100644 index 0000000..65e92e2 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolConnectionBehaviorTest.kt @@ -0,0 +1,301 @@ +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.ConnectionAttemptResult +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 + +@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( + 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 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + val result = session.connectWithFactory( + ready.connectionTarget, + { adapter }, + ready.expectedControllerIdentity + ) + assertTrue(result is ConnectionAttemptResult.Installed) + assertEquals(1, adapter.connectCount) + assertEquals(0, adapter.commandCount) + session.close() + } + + @Test + fun missingPassword_producesPasswordRequired() = runBlocking { + 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) + assertFalse(result.toString().contains("AB-CD-EF")) + } + + @Test + fun passwordPrompt_doesNotExposeSystemCode() = runBlocking { + 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")) + } + + @Test + fun failedReconnect_preservesProfile() = runBlocking { + 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 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + 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( + 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 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + assertTrue( + session.connectWithFactory(ready.connectionTarget, { adapter }) + is ConnectionAttemptResult.Installed + ) + repo.recordSuccessfulConnection(summary.id, 9999L) + assertEquals(9999L, repo.list()[0].lastSuccessAtMs) + session.close() + } + + @Test + fun noEquipmentCommand_duringSavedPoolReconnect() = runBlocking { + 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 + ) + 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( + 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 + ) + val ready = repo.loadConnection(summary.id) as SavedPoolConnectionResult.Ready + session.connectWithFactory(ready.connectionTarget, { adapter }) + 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(it, 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(it, 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) + vm.clearForTest() + } + + @Test + fun viewModel_passwordRequired_setsPromptWithoutSecrets() = runBlocking { + val summary = (repo.saveAfterSuccessfulConnection( + draft("SECRETCODE", "pw", false) + ) as SaveProfileResult.Saved).summary + val adapter = RecordingAdapter() + val vm = PoolViewModel( + preferences = prefs, savedPools = repo, + sessionFactory = { + SiteSession(it, 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 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 + ) : 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 (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() + } +} 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..e38a8a8 --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolRepositoryTest.kt @@ -0,0 +1,264 @@ +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 r = repo.saveAfterSuccessfulConnection(draft("AB-CD-EF", "pw", false)) + assertTrue(r is SaveProfileResult.Saved) + assertEquals(1, repo.list().size) + } + + @Test + fun passwordOmittedByDefault() = runBlocking { + 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 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")) + } + + @Test + fun blankPassword_succeeds_recordsNoPasswordRequired() = runBlocking { + 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 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(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( + draft("CODE", "pw", true, identity = identity) + ) as SaveProfileResult.Saved + val local = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "GW", "10.0.0.1", 80), + "", + identity, + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE, + displayName = "Home LAN" + ) + ) as SaveProfileResult.Saved + assertEquals(remote.summary.id, local.summary.id) + assertEquals(1, repo.list().size) + assertTrue(repo.list()[0].hasLocalRoute) + assertTrue(repo.list()[0].hasRemoteRoute) + } + + @Test + fun differentControllerIdentities_doNotMerge() = runBlocking { + 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_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( + draft("C1", "", false, name = "Old", ts = 100) + ) as SaveProfileResult.Saved + val newer = repo.saveAfterSuccessfulConnection( + draft("C2", "", false, name = "New", ts = 200) + ) as SaveProfileResult.Saved + val list = repo.list() + assertEquals(newer.summary.id, list[0].id) + assertEquals(older.summary.id, list[1].id) + } + + @Test + fun maxProfileCount_rejectsNew() = runBlocking { + repeat(5) { i -> + repo.saveAfterSuccessfulConnection( + draft("C$i", "", false, name = "P$i", identity = "id-$i", ts = i.toLong()) + ) + } + 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 + fun concurrentSaves_doNotCorruptVault() = runBlocking { + val jobs = (0 until 10).map { i -> + async { + repo.saveAfterSuccessfulConnection( + draft("CODE$i", "", false, name = "P$i", identity = "ctrl-$i", ts = i.toLong()) + ) + } + } + jobs.awaitAll() + val list = repo.list() + assertTrue(list.size in 1..5) + assertEquals(VaultHealth.AVAILABLE, repo.health()) + } + + @Test + fun failedDecrypt_doesNotOverwriteVault() = runBlocking { + repo.saveAfterSuccessfulConnection(draft("KEEP", "pw", true)) + val goodEnvelope = store.read()!! + 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") + } catch (e: VaultException) { + assertEquals(VaultHealth.AUTH_TAG_FAILURE, e.health) + } + assertTrue(goodEnvelope.contentEquals(store.read()!!)) + } + + @Test + fun delete_clearsControllerTempUnitWhenIdentityKnownAndUnshared() = runBlocking { + val identity = "pentair.screenlogic:controller:99" + ControllerTempUnitStore.save(prefs, identity, TemperatureUnit.CELSIUS) + val r = repo.saveAfterSuccessfulConnection( + draft("C", "", false, identity = identity) + ) as SaveProfileResult.Saved + assertNotNull(ControllerTempUnitStore.load(prefs, identity)) + 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 + KeyDeleteResult.Deleted + } + ) + r.saveAfterSuccessfulConnection(draft("C", "", false)) + assertTrue(r.resetVault() is VaultResetResult.Success) + assertTrue(keyReset) + assertTrue(r.list().isEmpty()) + assertFalse(store.exists()) + } + + @Test + fun localRoute_loadConnection_buildsTarget() = runBlocking { + val r = repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "Label", "192.168.1.20", 80), + "", + null, + PasswordSaveDecision.OMIT_FOR_NEW_PROFILE + ) + ) 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) + } + + @Test + fun summary_containsNoSecrets() = runBlocking { + 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(draft("SYS-CODE", "the-password", true)) + val snap = prefs.snapshot() + assertTrue(snap.isEmpty() || snap.values.none { + it.contains("SYS-CODE") || it.contains("the-password") + }) + } + + 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 new file mode 100644 index 0000000..de23c6d --- /dev/null +++ b/app/src/test/java/com/pentaircontrol/pool/savedpools/SavedPoolSecurityAndBackupTest.kt @@ -0,0 +1,141 @@ +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 + +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( + "SYS-CODE-99", "password-value", "Pool", null, + PasswordSaveDecision.STORE_NEW + ) + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromLocal( + DiscoveredGateway("g", "GW", "192.168.44.55", 8080), + "local-pass", null, PasswordSaveDecision.STORE_NEW + ) + ) + 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")) + } + + @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, + PasswordSaveDecision.STORE_NEW + ) + ) 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 = SimulatedAtomicVaultFileStore(noBackup) + store.writeAtomic(byteArrayOf(1, 2, 3, 4, 5)) + assertTrue(store.exists()) + assertTrue(store.baseFile().name.contains("saved_pools_v1.enc")) + store.delete() + noBackup.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(" 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()) + } + + @Test + fun keystoreAlias_isVersioned() { + assertTrue(AndroidKeystoreVaultCipher.KEY_ALIAS.contains("v1")) + } + + @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 + KeyDeleteResult.Deleted + } + ) + repo.saveAfterSuccessfulConnection( + SavedPoolDraftFactory.fromRemote( + "C", "p", "N", null, PasswordSaveDecision.STORE_NEW + ) + ) + assertTrue(repo.resetVault() is VaultResetResult.Success) + assertTrue(keyDeleted) + assertFalse(store.exists()) + assertEquals("dark", prefs.getString("westcurrent.appearance")) + } + + @Test + fun draftAndProfile_toString_redacted() { + val draft = SavedPoolDraftFactory.fromRemote( + "CODECODE", "passpass", "Name", null, PasswordSaveDecision.STORE_NEW + ) + assertFalse(draft.toString().contains("CODECODE")) + assertFalse(draft.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 new file mode 100644 index 0000000..c694013 --- /dev/null +++ b/docs/PHASE_1_6_SAVED_POOLS_SECURE_RECONNECT.md @@ -0,0 +1,374 @@ +# 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. + +--- + +## 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 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) | + +### Absolute prohibitions + +- 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 + +### Secret lifetime (1.6.1) + +`DefaultSavedPoolRepository` does **not** retain decrypted `SavedPoolProfile` / `SavedRoute` objects between calls. + +Every operation: + +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. + +--- + +## 2. Security boundaries + +| Layer | Responsibility | +| --- | --- | +| `SavedPoolSummary` / UI state | Non-secret only | +| `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 | + +--- + +## 3. Keystore key specification + +| Property | Value | +| --- | --- | +| Provider | `AndroidKeyStore` | +| Alias | `westcurrent.saved_pools.aes.v1` | +| Algorithm / size | AES / 256 | +| Mode / padding | GCM / none | +| Randomized encryption | 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. + +### Key deletion / reset + +`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 + +``` +"WCAE" | version=1 | ivLen=12 | iv[12] | ciphertext||tag(128) +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.” + +**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 + +`context.noBackupFilesDir / saved_pools_v1.enc` + +### AtomicFile (1.6.1 / 1.6.2) + +Production: `android.util.AtomicFile` + +- 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` + fault injection. Repository uses `read()` null as empty. + +--- + +## 6. Backup exclusions + +- `allowBackup=true` for non-sensitive prefs +- `backup_rules.xml` / `data_extraction_rules.xml` exclude vault +- Vault under `noBackupFilesDir` + +--- + +## 7. Saved-profile schema (payload v1) + +Magic `WCSV`, schema version `1`. + +Per profile: opaque UUID, display name, adapter ID, optional controller identity, created/last-success, preferred route, **isPreferred boolean**, routes. + +### Routes + +**Remote:** system code, optional password, passwordRequired, blankPasswordSucceeded +**Local:** address, port, gateway label, optional password, flags, optional controller identity + +### Sorting + +1. Preferred profile +2. Last successful connection time +3. Stable id tie-breaker + +Only a successful connection updates `lastSuccessAtMs`. Prefer does **not**. + +### Capacity + +Default max 40 profiles. New profiles at limit → `ProfileLimitReached` (no silent drop). Merge/update of existing profiles still works. + +--- + +## 8. Local and remote routes + +### Remote reconnect + +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 / 1.6.2) + +1. Try last-known address once +2. Pre-install identity validation when expected identity known +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) + +--- + +## 9. Consent and password behavior + +| Control | Default | +| --- | --- | +| Save this pool on this phone | Off | +| Remember password securely | Off | + +### PasswordSaveDecision (1.6.1 / 1.6.2) + +| 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 | + +**Normal connect / Save current (1.6.2):** Remember on → `STORE_NEW`; Remember off → `PRESERVE_EXISTING`. + +**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 **route-specific** remote/local password status and separate Forget actions. + +--- + +## 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) + +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. + +--- + +## 11. One-tap reconnect + attempt ownership (1.6.1) + +### ConnectionAttemptResult + +`Installed` | `Failed` | `Superseded` | `Rejected(code)` | `Closed` + +Save / last-success / password / active-profile updates only on **Installed** for that exact attempt generation and target. + +### ViewModel request epoch + +Starting request B invalidates A before A can decrypt, connect, install, save, or show a password prompt. Disconnect and ViewModel clear invalidate outstanding work. + +### 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 + +--- + +## 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). + +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). + +--- + +## 13. Compose sensitive state (1.6.1) + +Do **not** use `rememberSaveable` for: + +- System code +- Local address / port +- Passwords + +Use `remember`; system code masked by default with controlled reveal. Source guard test enforces this. + +--- + +## 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.” + +--- + +## 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, **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. + +--- + +## 16. Physical verification checklist (Frankie) + +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**. + +--- + +## 17. Known limitations + +- 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 + +--- + +## 18. Architecture summary + +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.