From 85455d98d6782d4e5fbf08339fb7c67a606b8473 Mon Sep 17 00:00:00 2001 From: laddyr141-ui Date: Mon, 27 Jul 2026 12:00:45 +0000 Subject: [PATCH 1/4] Add retry-with-backoff for idempotent ApiClient.get() calls A transient timeout (now bounded by HttpTimeout) previously surfaced immediately as ApiResult.Error with no retry. get() now retries transient network failures (timeouts, IOExceptions) with exponential backoff via a new RetryPolicy/withRetry helper, mirroring iOS's RetryPolicy.swift. post()/delete() remain single-attempt so a retried mutation can never double-submit (check-in, withdrawal, 2FA disable, ...). Co-Authored-By: Claude Sonnet 5 --- android/app/build.gradle.kts | 1 + .../java/com/ethosprotocol/api/ApiClient.kt | 36 +++++- .../java/com/ethosprotocol/api/RetryPolicy.kt | 37 ++++++ .../java/com/ethosprotocol/ApiClientTest.kt | 118 ++++++++++++++++++ android/gradle/libs.versions.toml | 1 + 5 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/ethosprotocol/api/RetryPolicy.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 720dbee..db1c0bf 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -139,6 +139,7 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) + testImplementation(libs.ktor.client.mock) androidTestImplementation(libs.androidx.test.ext) androidTestImplementation(libs.espresso.core) androidTestImplementation(platform(libs.compose.bom)) diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index ac6be0a..5a9d33c 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -8,7 +8,9 @@ import com.ethosprotocol.models.Enable2FAResponse import com.ethosprotocol.models.Verify2FARequest import io.ktor.client.* import io.ktor.client.call.* +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.android.* +import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* @@ -16,6 +18,7 @@ import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json +import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -26,13 +29,27 @@ sealed class ApiResult { } @Singleton -class ApiClient @Inject constructor( +class ApiClient( private val tokenProvider: TokenProvider, private val networkMonitor: NetworkMonitor, private val offlineCache: OfflineCache, - private val baseUrl: String + private val baseUrl: String, + engine: HttpClientEngine = Android.create(), + private val retryPolicy: RetryPolicy = RetryPolicy.networkDefault ) { - private val client = HttpClient(Android) { + // Hilt only ever needs to resolve the four production dependencies below — the + // engine and retry policy have safe defaults. Kept as a distinct @Inject + // constructor (rather than defaults on the primary one) because Dagger's codegen + // calls the full-arg constructor directly and does not honor Kotlin default + // values; tests use the primary constructor to inject a MockEngine/RetryPolicy. + @Inject constructor( + tokenProvider: TokenProvider, + networkMonitor: NetworkMonitor, + offlineCache: OfflineCache, + baseUrl: String + ) : this(tokenProvider, networkMonitor, offlineCache, baseUrl, Android.create(), RetryPolicy.networkDefault) + + private val client = HttpClient(engine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) } @@ -94,7 +111,9 @@ class ApiClient @Inject constructor( else ApiResult.NetworkUnavailable } return runCatching { - val response = client.get("$baseUrl$path") { bearerAuth() } + val response = withRetry(retryPolicy, ::isRetryableNetworkError) { + client.get("$baseUrl$path") { bearerAuth() } + } when (response.status.value) { in 200..299 -> { val body: T = response.body() @@ -143,6 +162,15 @@ class ApiClient @Inject constructor( }.getOrElse { ApiResult.Error(it.message ?: "Unknown error") } } + // GET is the only idempotent verb this client issues — retrying POST/DELETE + // automatically could double-submit a mutation (check-in, withdrawal, 2FA + // disable, ...), so only get() calls withRetry with this predicate. + // HttpRequestTimeoutException is checked explicitly because it subclasses + // CancellationException (so HttpTimeout can cooperate with coroutine + // cancellation) rather than IOException. + private fun isRetryableNetworkError(e: Throwable): Boolean = + e is HttpRequestTimeoutException || e is IOException + private fun HttpRequestBuilder.bearerAuth() { tokenProvider.token?.let { header(HttpHeaders.Authorization, "Bearer $it") } } diff --git a/android/app/src/main/java/com/ethosprotocol/api/RetryPolicy.kt b/android/app/src/main/java/com/ethosprotocol/api/RetryPolicy.kt new file mode 100644 index 0000000..de562aa --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/api/RetryPolicy.kt @@ -0,0 +1,37 @@ +package com.ethosprotocol.api + +import kotlinx.coroutines.delay + +// Retry configuration for idempotent network calls. ApiClient applies this only +// to GET requests — POST/DELETE must never be retried automatically, since a +// retried mutation (check-in, withdrawal, 2FA disable, ...) could double-submit. +data class RetryPolicy( + val maxAttempts: Int, + val baseDelayMillis: Long, + val sleep: suspend (Long) -> Unit = { delay(it) } +) { + companion object { + val networkDefault = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500) + } +} + +// Retries [operation] with exponential backoff (`baseDelayMillis * 2^attempt`) up to +// `policy.maxAttempts` total attempts, but only for errors [isRetryable] accepts. +// Any other error — or exhausting the attempt budget — is rethrown immediately. +suspend fun withRetry( + policy: RetryPolicy, + isRetryable: (Throwable) -> Boolean, + operation: suspend () -> T +): T { + var attempt = 0 + while (true) { + try { + return operation() + } catch (e: Throwable) { + attempt++ + if (attempt >= policy.maxAttempts || !isRetryable(e)) throw e + val delayMillis = policy.baseDelayMillis * (1L shl (attempt - 1)) + policy.sleep(delayMillis) + } + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt b/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt new file mode 100644 index 0000000..69fc8e2 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/ApiClientTest.kt @@ -0,0 +1,118 @@ +package com.ethosprotocol + +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.ApiResult +import com.ethosprotocol.api.NetworkMonitor +import com.ethosprotocol.api.OfflineCache +import com.ethosprotocol.api.RetryPolicy +import com.ethosprotocol.api.TokenProvider +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.http.headersOf +import io.mockk.every +import io.mockk.mockk +import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.test.runTest +import org.junit.Assert.* +import org.junit.Test + +class ApiClientTest { + + private val tokenProvider: TokenProvider = mockk { every { token } returns "test-token" } + private val networkMonitor: NetworkMonitor = mockk { every { isConnected } returns true } + private val offlineCache: OfflineCache = mockk(relaxed = true) + + private val challengeJson = """{"challenge":"abc","expires_at":"2026-01-01T00:00:00Z"}""" + + private fun client(engine: MockEngine, retryPolicy: RetryPolicy) = ApiClient( + tokenProvider, networkMonitor, offlineCache, "https://api.test", engine, retryPolicy + ) + + @Test + fun `get retries transient failures with exponential backoff and succeeds on the final attempt`() = runTest { + val callCount = AtomicInteger(0) + val delays = mutableListOf() + val engine = MockEngine { + if (callCount.getAndIncrement() < 2) throw IOException("connection reset") + respond(challengeJson, HttpStatusCode.OK, headersOf(HttpHeaders.ContentType, "application/json")) + } + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500, sleep = { delays.add(it) }) + + val result = client(engine, policy).getChallenge() + + assertTrue(result is ApiResult.Success) + assertEquals(3, callCount.get()) + assertEquals(listOf(500L, 1000L), delays) + } + + @Test + fun `get exhausts retry budget and surfaces an error without exceeding maxAttempts`() = runTest { + val callCount = AtomicInteger(0) + val delays = mutableListOf() + val engine = MockEngine { + callCount.incrementAndGet() + throw IOException("connection reset") + } + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500, sleep = { delays.add(it) }) + + val result = client(engine, policy).getChallenge() + + assertTrue(result is ApiResult.Error) + assertEquals(3, callCount.get()) + assertEquals(listOf(500L, 1000L), delays) + } + + @Test + fun `get does not retry non-transient errors`() = runTest { + val callCount = AtomicInteger(0) + val delays = mutableListOf() + val engine = MockEngine { + callCount.incrementAndGet() + throw IllegalStateException("boom") + } + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500, sleep = { delays.add(it) }) + + val result = client(engine, policy).getChallenge() + + assertTrue(result is ApiResult.Error) + assertEquals(1, callCount.get()) + assertTrue(delays.isEmpty()) + } + + @Test + fun `post never retries even on a transient failure`() = runTest { + val callCount = AtomicInteger(0) + val delays = mutableListOf() + val engine = MockEngine { + callCount.incrementAndGet() + throw IOException("connection reset") + } + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500, sleep = { delays.add(it) }) + + val result = client(engine, policy).checkIn("vault-1") + + assertTrue(result is ApiResult.Error) + assertEquals(1, callCount.get()) + assertTrue(delays.isEmpty()) + } + + @Test + fun `delete never retries even on a transient failure`() = runTest { + val callCount = AtomicInteger(0) + val delays = mutableListOf() + val engine = MockEngine { + callCount.incrementAndGet() + throw IOException("connection reset") + } + val policy = RetryPolicy(maxAttempts = 3, baseDelayMillis = 500, sleep = { delays.add(it) }) + + val result = client(engine, policy).unregisterPushToken("push-token") + + assertTrue(result is ApiResult.Error) + assertEquals(1, callCount.get()) + assertTrue(delays.isEmpty()) + } +} diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 0981e8e..01def60 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -43,6 +43,7 @@ ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } From ba85c5d1f2ddb9097fee6bc9574494b75448b30e Mon Sep 17 00:00:00 2001 From: laddyr141-ui Date: Mon, 27 Jul 2026 12:01:08 +0000 Subject: [PATCH 2/4] Validate beneficiary Stellar address format in CreateVaultDialog CreateVaultDialog only required beneficiary.isNotBlank() before enabling Create, matching iOS's #22 gap. Port iOS's StellarAddress StrKey validator (ed25519 public key: length/prefix/version byte/CRC16-XModem checksum) to Kotlin and require it before Create is enabled, with inline validation feedback in the dialog. Co-Authored-By: Claude Sonnet 5 --- .../ethosprotocol/models/StellarAddress.kt | 55 ++++++++++++++++++ .../com/ethosprotocol/ui/screens/Screens.kt | 22 +++++-- .../com/ethosprotocol/StellarAddressTest.kt | 58 +++++++++++++++++++ 3 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt diff --git a/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt new file mode 100644 index 0000000..47f9ce0 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/models/StellarAddress.kt @@ -0,0 +1,55 @@ +package com.ethosprotocol.models + +// Validates Stellar "G..." account IDs (StrKey-encoded ed25519 public keys) so a +// malformed beneficiary address is caught before it round-trips to the server. +// Structure per the StrKey spec: 1-byte version (6 << 3 for an ed25519 public +// key) + 32-byte payload + 2-byte CRC16/XModem checksum, base32-encoded +// (RFC 4648, no padding) to exactly 56 characters starting with "G". Kept +// dependency-free (no external Stellar SDK) since this is the only check the +// app needs. Mirrors iOS's StellarAddress.swift (#113). +object StellarAddress { + private val base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray() + private const val ED25519_PUBLIC_KEY_VERSION_BYTE: Int = 6 shl 3 + + fun isValidPublicKey(value: String): Boolean { + if (value.length != 56 || !value.startsWith("G")) return false + val decoded = base32Decode(value) ?: return false + if (decoded.size != 35) return false + if ((decoded[0].toInt() and 0xFF) != ED25519_PUBLIC_KEY_VERSION_BYTE) return false + + val versionAndPayload = decoded.copyOfRange(0, 33) + val expectedChecksum = crc16XModem(versionAndPayload) + val actualChecksum = (decoded[33].toInt() and 0xFF) or ((decoded[34].toInt() and 0xFF) shl 8) + return expectedChecksum == actualChecksum + } + + private fun base32Decode(string: String): ByteArray? { + val charIndex = HashMap() + base32Alphabet.forEachIndexed { i, c -> charIndex[c] = i } + + var bitBuffer = 0L + var bitCount = 0 + val bytes = mutableListOf() + for (char in string) { + val charValue = charIndex[char] ?: return null + bitBuffer = (bitBuffer shl 5) or charValue.toLong() + bitCount += 5 + if (bitCount >= 8) { + bitCount -= 8 + bytes.add(((bitBuffer shr bitCount) and 0xFF).toByte()) + } + } + return bytes.toByteArray() + } + + private fun crc16XModem(bytes: ByteArray): Int { + var crc = 0 + for (byte in bytes) { + crc = (crc xor ((byte.toInt() and 0xFF) shl 8)) and 0xFFFF + repeat(8) { + crc = (if (crc and 0x8000 != 0) (crc shl 1) xor 0x1021 else crc shl 1) and 0xFFFF + } + } + return crc + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 8ed6857..86888ae 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -431,14 +431,28 @@ fun VaultDeepLinkScreen( private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> Unit) { var beneficiary by remember { mutableStateOf("") } var days by remember { mutableStateOf(30f) } + var touched by remember { mutableStateOf(false) } + val isValidAddress = com.ethosprotocol.models.StellarAddress.isValidPublicKey(beneficiary) AlertDialog( onDismissRequest = onDismiss, title = { Text("New Vault") }, text = { Column { - OutlinedTextField(value = beneficiary, onValueChange = { beneficiary = it }, - label = { Text("Beneficiary address") }, singleLine = true, - modifier = Modifier.fillMaxWidth()) + OutlinedTextField( + value = beneficiary, + onValueChange = { beneficiary = it; touched = true }, + label = { Text("Beneficiary address") }, + singleLine = true, + isError = touched && beneficiary.isNotBlank() && !isValidAddress, + modifier = Modifier.fillMaxWidth() + ) + if (touched && beneficiary.isNotBlank() && !isValidAddress) { + Text( + "Enter a valid Stellar address (56 characters, starts with \"G\").", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } Spacer(Modifier.height(12.dp)) Text("Check-in interval: ${days.toInt()} days", style = MaterialTheme.typography.bodySmall) @@ -447,7 +461,7 @@ private fun CreateVaultDialog(onCreate: (String, Int) -> Unit, onDismiss: () -> }, confirmButton = { TextButton(onClick = { onCreate(beneficiary, days.toInt()) }, - enabled = beneficiary.isNotBlank()) { Text("Create") } + enabled = isValidAddress) { Text("Create") } }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } } ) diff --git a/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt new file mode 100644 index 0000000..c3dc7ea --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/StellarAddressTest.kt @@ -0,0 +1,58 @@ +package com.ethosprotocol + +import com.ethosprotocol.models.StellarAddress +import org.junit.Assert.* +import org.junit.Test + +// Verified valid StrKey ed25519 public keys (correct length, "G" prefix, version +// byte, and CRC16/XModem checksum) — same vectors used by iOS's StellarAddressTests +// so both platforms are validated against the same known-good/known-bad addresses. +class StellarAddressTest { + + private val validAddress = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF" + private val validAddress2 = "GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZX" + + @Test + fun `accepts well-formed addresses`() { + assertTrue(StellarAddress.isValidPublicKey(validAddress)) + assertTrue(StellarAddress.isValidPublicKey(validAddress2)) + } + + @Test + fun `rejects bad checksum`() { + // Same as validAddress2 but with the final checksum character flipped. + assertFalse(StellarAddress.isValidPublicKey("GAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7JZA")) + } + + @Test + fun `rejects wrong prefix`() { + assertFalse(StellarAddress.isValidPublicKey("M" + validAddress.drop(1))) + } + + @Test + fun `rejects too short`() { + assertFalse(StellarAddress.isValidPublicKey(validAddress.dropLast(1))) + } + + @Test + fun `rejects too long`() { + assertFalse(StellarAddress.isValidPublicKey(validAddress + "A")) + } + + @Test + fun `rejects lowercase`() { + assertFalse(StellarAddress.isValidPublicKey(validAddress.lowercase())) + } + + @Test + fun `rejects non base32 characters`() { + val chars = validAddress.toCharArray() + chars[10] = '0' // '0' is not in the Stellar base32 alphabet (A-Z, 2-7) + assertFalse(StellarAddress.isValidPublicKey(String(chars))) + } + + @Test + fun `rejects empty string`() { + assertFalse(StellarAddress.isValidPublicKey("")) + } +} From 1a4bffcd3080ceedad9e1d1d85de876595e80911 Mon Sep 17 00:00:00 2001 From: laddyr141-ui Date: Mon, 27 Jul 2026 12:02:46 +0000 Subject: [PATCH 3/4] Add pagination to vault listing ApiClient.listVaults() and VaultViewModel.load() assumed the full vault list fit in a single response, matching iOS's #21 gap. Add offset/limit query params to GET /vaults (documented in shared/api-contract.md as VaultPage, coordinated with #21), a VaultViewModel.loadMore() that appends subsequent pages, and a "Load more" control in VaultListScreen's LazyColumn. Co-Authored-By: Claude Sonnet 5 --- .../java/com/ethosprotocol/api/ApiClient.kt | 3 +- .../java/com/ethosprotocol/models/Models.kt | 10 ++++ .../java/com/ethosprotocol/ui/ViewModels.kt | 46 ++++++++++++++- .../com/ethosprotocol/ui/screens/Screens.kt | 9 +++ .../ethosprotocol/widget/VaultStatusWidget.kt | 4 +- .../com/ethosprotocol/VaultViewModelTest.kt | 56 +++++++++++++++++-- shared/api-contract.md | 12 +++- 7 files changed, 128 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index 5a9d33c..96b798f 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -73,7 +73,8 @@ class ApiClient( suspend fun registerPasskey(req: PasskeyRegisterRequest): ApiResult = post("/auth/register", req) // Vaults - suspend fun listVaults(): ApiResult> = get("/vaults") + suspend fun listVaults(offset: Int = 0, limit: Int = 20): ApiResult = + get("/vaults?offset=$offset&limit=$limit") suspend fun getVault(id: String): ApiResult = get("/vaults/$id") suspend fun createVault(req: CreateVaultRequest): ApiResult = post("/vaults", req) suspend fun checkIn(vaultId: String): ApiResult = post("/vaults/$vaultId/checkin", Unit) diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index ecb2df2..70042e8 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -18,6 +18,16 @@ data class Vault( val formattedBalance: String get() = "%.7f XLM".format(balance / 10_000_000.0) } +// A single page of GET /vaults, requested via `offset`/`limit` query params (see +// shared/api-contract.md). `nextOffset` is the offset to pass for the following +// page; null once `hasMore` is false. +@Serializable +data class VaultPage( + val vaults: List, + @SerialName("next_offset") val nextOffset: Int? = null, + @SerialName("has_more") val hasMore: Boolean = false +) + @Serializable enum class VaultStatus { active, expired, released, paused } diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index fd51d55..96540a1 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -127,8 +127,10 @@ class TwoFactorViewModel @Inject constructor( data class VaultUiState( val vaults: List = emptyList(), val isLoading: Boolean = false, + val isLoadingMore: Boolean = false, val error: String? = null, - val isOffline: Boolean = false + val isOffline: Boolean = false, + val hasMore: Boolean = false ) @HiltViewModel @@ -142,11 +144,21 @@ class VaultViewModel @Inject constructor( private val _state = MutableStateFlow(VaultUiState()) val state = _state.asStateFlow() + private var nextOffset = 0 + fun load() = viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } - when (val result = apiClient.listVaults()) { + when (val result = apiClient.listVaults(offset = 0, limit = PAGE_SIZE)) { is ApiResult.Success -> { - _state.update { it.copy(vaults = result.data, isLoading = false, isOffline = false) } + nextOffset = result.data.nextOffset ?: result.data.vaults.size + _state.update { + it.copy( + vaults = result.data.vaults, + isLoading = false, + isOffline = false, + hasMore = result.data.hasMore + ) + } } ApiResult.NetworkUnavailable -> { _state.update { it.copy(isLoading = false, isOffline = true) } @@ -157,6 +169,30 @@ class VaultViewModel @Inject constructor( } } + fun loadMore() = viewModelScope.launch { + val current = _state.value + if (current.isLoadingMore || !current.hasMore) return@launch + _state.update { it.copy(isLoadingMore = true, error = null) } + when (val result = apiClient.listVaults(offset = nextOffset, limit = PAGE_SIZE)) { + is ApiResult.Success -> { + nextOffset = result.data.nextOffset ?: (nextOffset + result.data.vaults.size) + _state.update { + it.copy( + vaults = it.vaults + result.data.vaults, + isLoadingMore = false, + hasMore = result.data.hasMore + ) + } + } + ApiResult.NetworkUnavailable -> { + _state.update { it.copy(isLoadingMore = false, error = "No network") } + } + is ApiResult.Error -> { + _state.update { it.copy(isLoadingMore = false, error = result.message) } + } + } + } + fun checkIn(vaultId: String) = viewModelScope.launch { when (val result = apiClient.checkIn(vaultId)) { is ApiResult.Success -> load() @@ -179,6 +215,10 @@ class VaultViewModel @Inject constructor( ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") } } } + + private companion object { + const val PAGE_SIZE = 20 + } } // --- Acceptance ViewModel --- diff --git a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt index 86888ae..1522abd 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/screens/Screens.kt @@ -161,6 +161,15 @@ fun VaultListScreen( onCheckIn = { pendingCheckIn = vault }, ) } + if (state.hasMore) item { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + if (state.isLoadingMore) { + CircularProgressIndicator(Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + OutlinedButton(onClick = { vm.loadMore() }) { Text("Load more") } + } + } + } } } } diff --git a/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt b/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt index 0d6ae13..b91a583 100644 --- a/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt +++ b/android/app/src/main/java/com/ethosprotocol/widget/VaultStatusWidget.kt @@ -76,9 +76,9 @@ class VaultWidgetUpdateWorker @AssistedInject constructor( ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { - val result = apiClient.listVaults() + val result = apiClient.listVaults(limit = 1) if (result is ApiResult.Success) { - val vault = result.data.firstOrNull() ?: return Result.success() + val vault = result.data.vaults.firstOrNull() ?: return Result.success() val ttl = formatTtl(vault.ttlRemaining) VaultStatusWidget.saveVaultData( applicationContext, diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index d3e8420..307d5e1 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -2,6 +2,7 @@ package com.ethosprotocol import com.ethosprotocol.api.ApiResult import com.ethosprotocol.models.Vault +import com.ethosprotocol.models.VaultPage import com.ethosprotocol.models.VaultStatus import com.ethosprotocol.ui.VaultUiState import com.ethosprotocol.ui.VaultViewModel @@ -46,18 +47,20 @@ class VaultViewModelTest { @Test fun `load success updates vaults`() = runTest { val vaults = listOf(makeVault("v1"), makeVault("v2")) - coEvery { apiClient.listVaults() } returns ApiResult.Success(vaults) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(vaults, nextOffset = null, hasMore = false)) vm.load() assertEquals(vaults, vm.state.value.vaults) assertFalse(vm.state.value.isLoading) + assertFalse(vm.state.value.hasMore) assertNull(vm.state.value.error) } @Test fun `load network unavailable sets offline flag`() = runTest { - coEvery { apiClient.listVaults() } returns ApiResult.NetworkUnavailable + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns ApiResult.NetworkUnavailable vm.load() @@ -67,7 +70,7 @@ class VaultViewModelTest { @Test fun `load error sets error message`() = runTest { - coEvery { apiClient.listVaults() } returns ApiResult.Error("Server error", 500) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns ApiResult.Error("Server error", 500) vm.load() @@ -75,16 +78,59 @@ class VaultViewModelTest { assertFalse(vm.state.value.isLoading) } + @Test + fun `load with more pages sets hasMore`() = runTest { + val vaults = listOf(makeVault("v1")) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(vaults, nextOffset = 20, hasMore = true)) + + vm.load() + + assertTrue(vm.state.value.hasMore) + } + + @Test + fun `loadMore appends the next page using the returned offset`() = runTest { + val firstPage = listOf(makeVault("v1")) + val secondPage = listOf(makeVault("v2")) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(firstPage, nextOffset = 20, hasMore = true)) + coEvery { apiClient.listVaults(offset = 20, limit = 20) } returns + ApiResult.Success(VaultPage(secondPage, nextOffset = null, hasMore = false)) + + vm.load() + vm.loadMore() + + assertEquals(firstPage + secondPage, vm.state.value.vaults) + assertFalse(vm.state.value.hasMore) + assertFalse(vm.state.value.isLoadingMore) + coVerify { apiClient.listVaults(offset = 20, limit = 20) } + } + + @Test + fun `loadMore is a no-op when hasMore is false`() = runTest { + val vaults = listOf(makeVault("v1")) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(vaults, nextOffset = null, hasMore = false)) + + vm.load() + vm.loadMore() + + assertEquals(vaults, vm.state.value.vaults) + coVerify(exactly = 0) { apiClient.listVaults(offset = 20, limit = 20) } + } + @Test fun `checkIn success reloads vaults`() = runTest { val vaults = listOf(makeVault("v1")) coEvery { apiClient.checkIn("v1") } returns ApiResult.Success(Unit) - coEvery { apiClient.listVaults() } returns ApiResult.Success(vaults) + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(vaults, nextOffset = null, hasMore = false)) vm.checkIn("v1") coVerify { apiClient.checkIn("v1") } - coVerify { apiClient.listVaults() } + coVerify { apiClient.listVaults(offset = 0, limit = 20) } } @Test diff --git a/shared/api-contract.md b/shared/api-contract.md index b50946e..5950e14 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -18,7 +18,7 @@ JWT is obtained via Passkey (WebAuthn) challenge/response flow. ### Vaults | Method | Path | Description | |--------|------|-------------| -| GET | `/vaults` | List owner's vaults | +| GET | `/vaults?offset={offset}&limit={limit}` | List owner's vaults, paged. `offset` defaults to `0`, `limit` defaults to `20` (max `100`). Returns a `VaultPage` (see below). | | POST | `/vaults` | Create vault | | GET | `/vaults/{id}` | Get vault detail | | POST | `/vaults/{id}/checkin` | Check in (extend TTL) | @@ -52,6 +52,16 @@ JWT is obtained via Passkey (WebAuthn) challenge/response flow. } ``` +### VaultPage +```json +{ + "vaults": [ /* Vault, see above */ ], + "next_offset": 20, + "has_more": true +} +``` +`next_offset` is the `offset` to request the following page; `null` once `has_more` is `false`. + ### AuthChallenge ```json { "challenge": "base64url", "expires_at": "ISO8601" } From 0dcb4be2b7669379d81a59df7ce068f39e844557 Mon Sep 17 00:00:00 2001 From: laddyr141-ui Date: Mon, 27 Jul 2026 12:04:34 +0000 Subject: [PATCH 4/4] Add WebSocket client for real-time vault events shared/api-contract.md documents wss://.../ws?vault_id={id}, but no client existed anywhere in the Android app and Ktor's WebSockets plugin wasn't installed, matching iOS's #20 gap. Install WebSockets on the existing HttpClient in ApiClient, add a VaultEventSocket service with reconnect/exponential-backoff (resetting on each successful connection), and wire it into VaultViewModel to update the matching vault in place as events arrive. Co-Authored-By: Claude Sonnet 5 --- android/app/build.gradle.kts | 1 + .../java/com/ethosprotocol/api/ApiClient.kt | 14 ++- .../java/com/ethosprotocol/models/Models.kt | 9 ++ .../services/VaultEventSocket.kt | 90 +++++++++++++++++ .../java/com/ethosprotocol/ui/ViewModels.kt | 27 ++++++ .../com/ethosprotocol/VaultEventSocketTest.kt | 96 +++++++++++++++++++ .../com/ethosprotocol/VaultViewModelTest.kt | 24 ++++- android/gradle/libs.versions.toml | 1 + shared/api-contract.md | 7 +- 9 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt create mode 100644 android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index db1c0bf..fbea423 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -102,6 +102,7 @@ dependencies { implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.ktor.client.logging) + implementation(libs.ktor.client.websockets) // Serialization implementation(libs.kotlinx.serialization.json) diff --git a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt index 96b798f..1f66c47 100644 --- a/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt +++ b/android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt @@ -14,6 +14,7 @@ import io.ktor.client.plugins.HttpRequestTimeoutException import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.plugins.logging.* +import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* @@ -49,7 +50,10 @@ class ApiClient( baseUrl: String ) : this(tokenProvider, networkMonitor, offlineCache, baseUrl, Android.create(), RetryPolicy.networkDefault) - private val client = HttpClient(engine) { + // internal (not private): VaultEventSocket reuses this same client/connection pool + // to open the `/ws` connection documented in shared/api-contract.md, rather than + // standing up a second HttpClient. + internal val client = HttpClient(engine) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true }) } @@ -65,6 +69,14 @@ class ApiClient( connectTimeoutMillis = 15_000 socketTimeoutMillis = 30_000 } + install(WebSockets) + } + + // Derives the `wss://.../ws?vault_id={id}` URL from [baseUrl] (documented in + // shared/api-contract.md) for VaultEventSocket. + internal fun webSocketUrl(vaultId: String): String { + val wsBase = baseUrl.replaceFirst("https://", "wss://").replaceFirst("http://", "ws://") + return "$wsBase/ws?vault_id=$vaultId" } // Auth diff --git a/android/app/src/main/java/com/ethosprotocol/models/Models.kt b/android/app/src/main/java/com/ethosprotocol/models/Models.kt index 70042e8..8a73622 100644 --- a/android/app/src/main/java/com/ethosprotocol/models/Models.kt +++ b/android/app/src/main/java/com/ethosprotocol/models/Models.kt @@ -18,6 +18,15 @@ data class Vault( val formattedBalance: String get() = "%.7f XLM".format(balance / 10_000_000.0) } +// A single real-time event delivered over the `wss://.../ws?vault_id={id}` socket +// (see shared/api-contract.md). `vault` carries the full updated Vault so consumers +// can update state in place without an extra round trip. +@Serializable +data class VaultEvent( + val type: String, + val vault: Vault? = null +) + // A single page of GET /vaults, requested via `offset`/`limit` query params (see // shared/api-contract.md). `nextOffset` is the offset to pass for the following // page; null once `hasMore` is false. diff --git a/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt b/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt new file mode 100644 index 0000000..de34f43 --- /dev/null +++ b/android/app/src/main/java/com/ethosprotocol/services/VaultEventSocket.kt @@ -0,0 +1,90 @@ +package com.ethosprotocol.services + +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.VaultEvent +import io.ktor.client.plugins.websocket.webSocketSession +import io.ktor.client.request.header +import io.ktor.client.request.url +import io.ktor.http.HttpHeaders +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.ktor.websocket.readText +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.isActive +import kotlinx.serialization.json.Json + +// Reconnect/backoff schedule for VaultEventSocket. Unlike RetryPolicy (bounded +// attempts for a single request), a dropped socket should keep retrying +// indefinitely with the delay capped so a long outage doesn't leave the client +// waiting minutes between attempts once it reconnects. +data class ReconnectBackoff( + val baseDelayMillis: Long, + val maxDelayMillis: Long, + val sleep: suspend (Long) -> Unit = { delay(it) } +) { + fun delayForAttempt(attempt: Int): Long { + val shift = attempt.coerceIn(0, 20) + return (baseDelayMillis * (1L shl shift)).coerceAtMost(maxDelayMillis) + } + + companion object { + val default = ReconnectBackoff(baseDelayMillis = 1_000, maxDelayMillis = 30_000) + } +} + +// Client for the `wss://.../ws?vault_id={id}` endpoint (shared/api-contract.md). +// Reuses ApiClient's HttpClient/WebSockets plugin rather than a second client. +// A dropped or failed connection reconnects with exponential backoff +// ([ReconnectBackoff]) until the collecting coroutine is cancelled; the backoff +// resets once a new connection is established. +@Singleton +class VaultEventSocket( + private val apiClient: ApiClient, + private val tokenProvider: TokenProvider, + private val backoff: ReconnectBackoff = ReconnectBackoff.default +) { + // Distinct @Inject constructor for the same reason as ApiClient's: Dagger's + // codegen calls the full-arg constructor directly and ignores Kotlin default + // values, so Hilt needs an explicit constructor matching only its bindings. + @Inject constructor(apiClient: ApiClient, tokenProvider: TokenProvider) : + this(apiClient, tokenProvider, ReconnectBackoff.default) + + // internal (not private): tests replace this to simulate connect failures/frames + // without a real server, matching how ApiClient exposes its engine to tests. + internal var openSession: suspend (String) -> WebSocketSession = { vaultId -> + apiClient.client.webSocketSession { + url(apiClient.webSocketUrl(vaultId)) + tokenProvider.token?.let { header(HttpHeaders.Authorization, "Bearer $it") } + } + } + + fun events(vaultId: String): Flow = flow { + var attempt = 0 + while (currentCoroutineContext().isActive) { + try { + val session = openSession(vaultId) + attempt = 0 + for (frame in session.incoming) { + if (frame is Frame.Text) { + runCatching { Json.decodeFromString(frame.readText()) } + .onSuccess { emit(it) } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Connection failed or dropped — fall through to backoff and reconnect. + } + if (!currentCoroutineContext().isActive) break + backoff.sleep(backoff.delayForAttempt(attempt)) + attempt++ + } + } +} diff --git a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt index 96540a1..c514b56 100644 --- a/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt +++ b/android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt @@ -20,8 +20,10 @@ import com.ethosprotocol.services.NotificationHelper import com.ethosprotocol.services.PasskeyService import com.ethosprotocol.services.PendingCheckIn import com.ethosprotocol.services.PendingCheckInDao +import com.ethosprotocol.services.VaultEventSocket import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update @@ -138,6 +140,7 @@ class VaultViewModel @Inject constructor( private val apiClient: ApiClient, private val notificationHelper: NotificationHelper, private val pendingCheckInDao: PendingCheckInDao, + private val vaultEventSocket: VaultEventSocket, @ApplicationContext private val context: Context ) : ViewModel() { @@ -145,6 +148,7 @@ class VaultViewModel @Inject constructor( val state = _state.asStateFlow() private var nextOffset = 0 + private val eventJobs = mutableMapOf() fun load() = viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } @@ -159,6 +163,7 @@ class VaultViewModel @Inject constructor( hasMore = result.data.hasMore ) } + subscribeToEvents(result.data.vaults.map { it.id }) } ApiResult.NetworkUnavailable -> { _state.update { it.copy(isLoading = false, isOffline = true) } @@ -183,6 +188,7 @@ class VaultViewModel @Inject constructor( hasMore = result.data.hasMore ) } + subscribeToEvents(_state.value.vaults.map { it.id }) } ApiResult.NetworkUnavailable -> { _state.update { it.copy(isLoadingMore = false, error = "No network") } @@ -193,6 +199,27 @@ class VaultViewModel @Inject constructor( } } + // Keeps one VaultEventSocket subscription per vault currently in [_state], so + // check-ins/deposits/withdrawals made elsewhere (another device, an expiry) + // update this list in place instead of requiring a manual refresh. + private fun subscribeToEvents(vaultIds: List) { + val currentIds = vaultIds.toSet() + eventJobs.keys.filter { it !in currentIds }.forEach { id -> eventJobs.remove(id)?.cancel() } + currentIds.filterNot { eventJobs.containsKey(it) }.forEach { id -> + eventJobs[id] = viewModelScope.launch { + vaultEventSocket.events(id).collect { event -> + val updated = event.vault ?: return@collect + _state.update { s -> s.copy(vaults = s.vaults.map { if (it.id == updated.id) updated else it }) } + } + } + } + } + + override fun onCleared() { + eventJobs.values.forEach { it.cancel() } + eventJobs.clear() + } + fun checkIn(vaultId: String) = viewModelScope.launch { when (val result = apiClient.checkIn(vaultId)) { is ApiResult.Success -> load() diff --git a/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt new file mode 100644 index 0000000..6401319 --- /dev/null +++ b/android/app/src/test/java/com/ethosprotocol/VaultEventSocketTest.kt @@ -0,0 +1,96 @@ +package com.ethosprotocol + +import com.ethosprotocol.api.ApiClient +import com.ethosprotocol.api.TokenProvider +import com.ethosprotocol.models.VaultEvent +import com.ethosprotocol.services.ReconnectBackoff +import com.ethosprotocol.services.VaultEventSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.WebSocketSession +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import java.io.IOException +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Test + +class VaultEventSocketTest { + + private val apiClient: ApiClient = mockk(relaxed = true) + private val tokenProvider: TokenProvider = mockk { every { token } returns "test-token" } + + @Test + fun `delayForAttempt doubles each attempt and caps at maxDelayMillis`() { + val backoff = ReconnectBackoff(baseDelayMillis = 1_000, maxDelayMillis = 30_000) + + assertEquals(1_000L, backoff.delayForAttempt(0)) + assertEquals(2_000L, backoff.delayForAttempt(1)) + assertEquals(4_000L, backoff.delayForAttempt(2)) + assertEquals(8_000L, backoff.delayForAttempt(3)) + assertEquals(30_000L, backoff.delayForAttempt(10)) + } + + @Test + fun `events reconnects with backoff after a connection failure and resumes emitting`() = runTest { + val delays = mutableListOf() + val backoff = ReconnectBackoff(baseDelayMillis = 1_000, maxDelayMillis = 30_000, sleep = { delays.add(it) }) + val socket = VaultEventSocket(apiClient, tokenProvider, backoff) + + val openAttempts = AtomicInteger(0) + val channel = Channel(capacity = 1) + val event = VaultEvent(type = "check_in", vault = null) + channel.trySend(Frame.Text(Json.encodeToString(VaultEvent.serializer(), event))) + + socket.openSession = { + if (openAttempts.getAndIncrement() == 0) throw IOException("connection refused") + mockk { every { incoming } returns channel } + } + + val received = socket.events("vault-1").take(1).toList() + + assertEquals(listOf(event), received) + assertEquals(2, openAttempts.get()) + assertEquals(listOf(1_000L), delays) + } + + @Test + fun `events resets the backoff attempt counter after a successful connection`() = runTest { + val delays = mutableListOf() + val backoff = ReconnectBackoff(baseDelayMillis = 1_000, maxDelayMillis = 30_000, sleep = { delays.add(it) }) + val socket = VaultEventSocket(apiClient, tokenProvider, backoff) + + // Sequence: fail, succeed-then-immediately-close (no frames), fail, succeed-with-a-frame. + val openAttempts = AtomicInteger(0) + val event = VaultEvent(type = "check_in", vault = null) + socket.openSession = { + when (openAttempts.getAndIncrement()) { + 0 -> throw IOException("connection refused") + 1 -> mockk { every { incoming } returns Channel().apply { close() } } + 2 -> throw IOException("connection refused") + else -> mockk { + every { incoming } returns Channel(capacity = 1).apply { + trySend(Frame.Text(Json.encodeToString(VaultEvent.serializer(), event))) + } + } + } + } + + val received = socket.events("vault-1").take(1).toList() + + assertEquals(listOf(event), received) + assertEquals(4, openAttempts.get()) + // open#0 fails at attempt=0 -> delay 1000ms, attempt->1. + // open#1 connects (then closes with no frames) -> attempt reset to 0 -> delay + // 1000ms again (not 2000ms), proving the reset, attempt->1. + // open#2 fails at attempt=1 -> delay 2000ms, attempt->2. + // open#3 connects and delivers the frame; take(1) ends collection before any + // further delay is recorded. + assertEquals(listOf(1_000L, 1_000L, 2_000L), delays) + } +} diff --git a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt index 307d5e1..d9e00d3 100644 --- a/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt +++ b/android/app/src/test/java/com/ethosprotocol/VaultViewModelTest.kt @@ -2,6 +2,7 @@ package com.ethosprotocol import com.ethosprotocol.api.ApiResult import com.ethosprotocol.models.Vault +import com.ethosprotocol.models.VaultEvent import com.ethosprotocol.models.VaultPage import com.ethosprotocol.models.VaultStatus import com.ethosprotocol.ui.VaultUiState @@ -10,10 +11,13 @@ import com.ethosprotocol.api.ApiClient import com.ethosprotocol.services.CheckInSyncWorker import com.ethosprotocol.services.NotificationHelper import com.ethosprotocol.services.PendingCheckInDao +import com.ethosprotocol.services.VaultEventSocket import android.content.Context import io.mockk.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.test.* import org.junit.After import org.junit.Assert.* @@ -27,6 +31,7 @@ class VaultViewModelTest { private val apiClient: ApiClient = mockk() private val notificationHelper: NotificationHelper = mockk(relaxed = true) private val pendingCheckInDao: PendingCheckInDao = mockk(relaxed = true) + private val vaultEventSocket: VaultEventSocket = mockk() private val context: Context = mockk(relaxed = true) private lateinit var vm: VaultViewModel @@ -35,7 +40,8 @@ class VaultViewModelTest { Dispatchers.setMain(testDispatcher) mockkObject(CheckInSyncWorker.Companion) every { CheckInSyncWorker.schedule(any()) } just Runs - vm = VaultViewModel(apiClient, notificationHelper, pendingCheckInDao, context) + every { vaultEventSocket.events(any()) } returns emptyFlow() + vm = VaultViewModel(apiClient, notificationHelper, pendingCheckInDao, vaultEventSocket, context) } @After @@ -120,6 +126,22 @@ class VaultViewModelTest { coVerify(exactly = 0) { apiClient.listVaults(offset = 20, limit = 20) } } + @Test + fun `vault event updates the matching vault in place`() = runTest { + val vaults = listOf(makeVault("v1"), makeVault("v2")) + val events = MutableSharedFlow() + every { vaultEventSocket.events("v1") } returns events + every { vaultEventSocket.events("v2") } returns emptyFlow() + coEvery { apiClient.listVaults(offset = 0, limit = 20) } returns + ApiResult.Success(VaultPage(vaults, nextOffset = null, hasMore = false)) + + vm.load() + val updatedV1 = makeVault("v1").copy(balance = 999L) + events.emit(VaultEvent(type = "deposit", vault = updatedV1)) + + assertEquals(listOf(updatedV1, vaults[1]), vm.state.value.vaults) + } + @Test fun `checkIn success reloads vaults`() = runTest { val vaults = listOf(makeVault("v1")) diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 01def60..215ff86 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -43,6 +43,7 @@ ktor-client-android = { group = "io.ktor", name = "ktor-client-android", version ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" } ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } diff --git a/shared/api-contract.md b/shared/api-contract.md index 5950e14..2d7f20e 100644 --- a/shared/api-contract.md +++ b/shared/api-contract.md @@ -34,7 +34,12 @@ JWT is obtained via Passkey (WebAuthn) challenge/response flow. | DELETE | `/notifications/register` | Unregister push token | ## WebSocket -`wss://api.ethos-protocol.app/v1/ws?vault_id={id}` — real-time vault events +`wss://api.ethos-protocol.app/v1/ws?vault_id={id}` — real-time vault events. +Authenticate the same way as REST: `Authorization: Bearer ` header on the +upgrade request. Each text frame is a `VaultEvent`: +```json +{ "type": "check_in|deposit|withdraw|status_change", "vault": { /* Vault, see above */ } } +``` ## Models