Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,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)
Expand Down
17 changes: 15 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ 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.*
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
Expand All @@ -33,7 +35,7 @@ sealed class ApiResult<out T> {
}

@Singleton
class ApiClient @Inject constructor(
class ApiClient(
private val tokenProvider: TokenProvider,
private val networkMonitor: NetworkMonitor,
private val offlineCache: OfflineCache,
Expand Down Expand Up @@ -152,7 +154,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()
Expand Down Expand Up @@ -220,6 +224,15 @@ class ApiClient @Inject constructor(
if (result is ApiResult.Success) tokenProvider.setSession(result.data)
}

// 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") }
}
Expand Down
37 changes: 37 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/api/RetryPolicy.kt
Original file line number Diff line number Diff line change
@@ -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 <T> 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)
}
}
}
19 changes: 19 additions & 0 deletions android/app/src/main/java/com/ethosprotocol/models/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,25 @@ 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.
@Serializable
data class VaultPage(
val vaults: List<Vault>,
@SerialName("next_offset") val nextOffset: Int? = null,
@SerialName("has_more") val hasMore: Boolean = false
)

@Serializable
enum class VaultStatus { active, expired, released, paused }

Expand Down
Original file line number Diff line number Diff line change
@@ -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<VaultEvent> = 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<VaultEvent>(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++
}
}
}
17 changes: 15 additions & 2 deletions android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ class TwoFactorViewModel @Inject constructor(
data class VaultUiState(
val vaults: List<Vault> = emptyList(),
val isLoading: Boolean = false,
val isLoadingMore: Boolean = false,
val error: String? = null,
val isOffline: Boolean = false,
val beneficiaryUpdated: Boolean = false
Expand All @@ -268,11 +269,23 @@ class VaultViewModel @Inject constructor(
private val _state = MutableStateFlow(VaultUiState())
val state = _state.asStateFlow()

private var nextOffset = 0
private val eventJobs = mutableMapOf<String, Job>()

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
)
}
subscribeToEvents(result.data.vaults.map { it.id })
}
ApiResult.NetworkUnavailable -> {
_state.update { it.copy(isLoading = false, isOffline = true) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,15 @@ fun VaultListScreen(
)
}
}
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") }
}
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ 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) {
// Pick the active vault with the lowest ttlRemaining — the same
// "most urgent vault" selection that iOS TTLTimelineProvider uses
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Long>()
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<Frame>(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<WebSocketSession> { 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<Long>()
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<WebSocketSession> { every { incoming } returns Channel<Frame>().apply { close() } }
2 -> throw IOException("connection refused")
else -> mockk<WebSocketSession> {
every { incoming } returns Channel<Frame>(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)
}
}
Loading