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
32 changes: 29 additions & 3 deletions android/app/src/main/java/com/ethosprotocol/api/ApiClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ 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.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -120,6 +122,15 @@ class ApiClient(
suspend fun completeRecovery(req: RecoveryCompleteRequest): ApiResult<Unit> =
post("/auth/recovery/complete", req)

// Sessions (#208)
suspend fun listSessions(): ApiResult<List<Session>> = get("/auth/sessions")

// "Sign out this device" for a specific session (may be the caller's own current session).
suspend fun revokeSession(id: String): ApiResult<Unit> = delete("/auth/sessions/$id", Unit)

// "Sign out all other devices" — revokes every session except the one making this call.
suspend fun revokeOtherSessions(): ApiResult<Unit> = delete("/auth/sessions", Unit)

// Vaults
suspend fun listVaults(): ApiResult<List<Vault>> = get("/vaults")

Expand Down Expand Up @@ -189,7 +200,10 @@ class ApiClient(
}
// The token the server rejected is no longer valid — clear it locally so it
// isn't kept being sent, and so the UI correctly routes back to AuthScreen.
401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) }
// #211: a 401 can carry a human-readable reason in its body (e.g. an expired
// recovery token/proof on completeRecovery) — surface it instead of the
// generic "Unauthorized" so the caller isn't left with a dead-end message.
401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) }
404 -> ApiResult.Error("Not found", 404)
else -> ApiResult.Error("Server error ${response.status.value}", response.status.value)
}
Expand All @@ -212,7 +226,10 @@ class ApiClient(
}
when (response.status.value) {
in 200..299 -> ApiResult.Success(if (T::class == Unit::class) Unit as T else response.body())
401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) }
// #211: a 401 can carry a human-readable reason in its body (e.g. an expired
// recovery token/proof on completeRecovery) — surface it instead of the
// generic "Unauthorized" so the caller isn't left with a dead-end message.
401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) }
else -> ApiResult.Error("Server error ${response.status.value}", response.status.value)
}
}.getOrElse { e -> ApiErrorMapper.toApiResult(e) { if (BuildConfig.DEBUG) Log.w(TAG, "$path failed", it) } }
Expand All @@ -233,7 +250,10 @@ class ApiClient(
// deletion (401/500/etc.) is silently reported back to callers as success.
when (response.status.value) {
in 200..299 -> ApiResult.Success(if (T::class == Unit::class) Unit as T else response.body())
401 -> { tokenProvider.clear(); ApiResult.Error("Unauthorized", 401) }
// #211: a 401 can carry a human-readable reason in its body (e.g. an expired
// recovery token/proof on completeRecovery) — surface it instead of the
// generic "Unauthorized" so the caller isn't left with a dead-end message.
401 -> { tokenProvider.clear(); ApiResult.Error(response.unauthorizedMessage(), 401) }
else -> ApiResult.Error("Server error ${response.status.value}", response.status.value)
}
}.getOrElse { e -> ApiErrorMapper.toApiResult(e) { if (BuildConfig.DEBUG) Log.w(TAG, "$path failed", it) } }
Expand All @@ -254,6 +274,12 @@ class ApiClient(
// HttpRequestTimeoutException is checked explicitly because it subclasses
// CancellationException (so HttpTimeout can cooperate with coroutine
// cancellation) rather than IOException.
// #211: reads the `{"error": "<message>"}` body a 401 response may carry, falling back to
// "Unauthorized" when there's no body (the normal case for a rejected session token).
private suspend fun HttpResponse.unauthorizedMessage(): String =
runCatching { Json.decodeFromString<Map<String, String>>(bodyAsText())["error"] }
.getOrNull() ?: "Unauthorized"

private fun isRetryableNetworkError(e: Throwable): Boolean =
e is HttpRequestTimeoutException || e is IOException

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import javax.net.ssl.SSLException

// Marks a message that is already user-presentable (server status text, "No network
// connection") so ApiErrorMapper passes it through instead of replacing it with the
// generic fallback.
class ApiCallFailedException(message: String) : Exception(message)
// generic fallback. `code` carries the originating ApiResult.Error's HTTP status (0 when
// there wasn't one, e.g. "No network connection") so callers like PasskeyService's recovery
// flow (#211) can distinguish "the token/proof was rejected" (401) from other failures
// without parsing the message text.
class ApiCallFailedException(message: String, val code: Int = 0) : Exception(message)

object ApiErrorMapper {

Expand Down
15 changes: 15 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 @@ -98,6 +98,21 @@ data class RecoveryCompleteRequest(
@SerialName("client_data_json") val clientDataJson: String
)

// MARK: - Sessions (#208)
//
// A device currently holding a valid JWT for this account. Backs SessionsScreen's device
// list and its "Sign out this device" / "Sign out all other devices" actions.

@Serializable
data class Session(
val id: String,
@SerialName("device_name") val deviceName: String,
val platform: String,
@SerialName("created_at") val createdAt: String,
@SerialName("last_active_at") val lastActiveAt: String,
@SerialName("is_current") val isCurrent: Boolean
)

// MARK: - 2FA Models

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,15 @@ class PasskeyService @Inject constructor(
val requestJson = PasskeyRequestBuilder.registrationRequestJson(challenge, normalizedUsername)

val credManager = credentialManagerFactory.create(activity)
val resp = credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson))
as CreatePublicKeyCredentialResponse
// #210: devices with no biometric enrolled (or biometrics disabled by policy) throw
// here — map to tailored copy instead of letting a generic CredentialManager error
// reach the user with no indication of what to actually do about it.
val resp = try {
credManager.createCredential(activity, CreatePublicKeyCredentialRequest(requestJson))
as CreatePublicKeyCredentialResponse
} catch (e: CreateCredentialException) {
throw PasskeyException(mapCreateCredentialError(e))
}
val json = JSONObject(resp.registrationResponseJson)
val regReq = PasskeyRegisterRequest(
credentialId = json.getString("id"),
Expand Down Expand Up @@ -124,8 +131,12 @@ class PasskeyService @Inject constructor(
// subtypes that carry actionable meaning; anything else falls back to a generic retry prompt.
private fun mapCreateCredentialError(e: CreateCredentialException): String = when (e) {
is CreateCredentialCancellationException -> "Sign-up canceled."
// #210: this fires on devices with no biometric enrolled (or an unlockable screen
// lock at all) — e.g. older/budget hardware below this app's usual assumptions, or
// biometrics disabled by MDM policy. Direct the user to a fix instead of a dead end.
is CreateCredentialNoCreateOptionException ->
"This device can't create a passkey. Add a screen lock in Settings and try again."
"This device has no biometric or screen lock set up, so it can't create a passkey. " +
"Enroll a fingerprint or face unlock, or set a device PIN/passcode, in Settings and try again."
is CreateCredentialProviderConfigurationException ->
"No passkey provider is set up on this device."
is CreateCredentialInterruptedException -> "Setup was interrupted — please try again."
Expand All @@ -145,7 +156,7 @@ class PasskeyService @Inject constructor(
internal fun <T> requireSuccess(result: ApiResult<T>): T {
return when (result) {
is ApiResult.Success -> result.data
is ApiResult.Error -> throw ApiCallFailedException(result.message)
is ApiResult.Error -> throw ApiCallFailedException(result.message, result.code)
ApiResult.NetworkUnavailable -> throw ApiCallFailedException("No network connection")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import com.ethosprotocol.ui.screens.DepositScreen
import com.ethosprotocol.ui.screens.VaultDeepLinkScreen
import com.ethosprotocol.ui.screens.VaultListScreen
import com.ethosprotocol.ui.screens.WithdrawScreen
import com.ethosprotocol.ui.screens.SessionsScreen
import com.ethosprotocol.ui.theme.EthosProtocolTheme
import dagger.hilt.android.AndroidEntryPoint

Expand Down Expand Up @@ -220,7 +221,13 @@ private fun AppNavigation(
NavHost(navController, startDestination = if (authState.isAuthenticated) "vaults" else "auth") {
composable("auth") { AuthScreen(vm = authVm) }
composable("vaults") {
VaultListScreen(onVaultClick = { /* navigate to detail */ })
VaultListScreen(
onVaultClick = { /* navigate to detail */ },
onSessionsClick = { navController.navigate("sessions") }
)
}
composable("sessions") {
SessionsScreen(onBack = { navController.popBackStack() })
}
composable("accept/{vaultId}/{token}") { backStack ->
val vaultId = backStack.arguments?.getString("vaultId") ?: return@composable
Expand Down
128 changes: 127 additions & 1 deletion android/app/src/main/java/com/ethosprotocol/ui/ViewModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.ethosprotocol.BuildConfig
import com.ethosprotocol.api.ApiCallFailedException
import com.ethosprotocol.api.ApiClient
import com.ethosprotocol.api.ApiErrorMapper
import com.ethosprotocol.api.ApiResult
Expand All @@ -21,6 +22,7 @@ import com.ethosprotocol.models.Enable2FARequest
import com.ethosprotocol.models.Enable2FAResponse
import com.ethosprotocol.models.Verify2FARequest
import com.ethosprotocol.services.NotificationHelper
import com.ethosprotocol.services.PasskeyException
import com.ethosprotocol.services.PasskeyService
import com.ethosprotocol.services.PendingAction
import com.ethosprotocol.services.PendingActionDao
Expand Down Expand Up @@ -72,6 +74,38 @@ class AuthViewModel @Inject constructor(
private var consecutiveFailures = 0
private var cooldownJob: Job? = null

// #209: proactive token refresh, scheduled independently of request activity — ApiClient's
// ensureFreshToken() already covers "refresh before the next API call", but a foregrounded,
// idle app makes no calls and would otherwise sit on a token until it's rejected. Polling
// (rather than a one-shot timer at the exact expiry, as AuthStore.swift does) is used because
// TokenProvider only exposes isNearExpiry(), not the raw expiry instant.
private var refreshJob: Job? = null

init {
if (tokenProvider.token != null) startScheduledRefresh()
}

private fun startScheduledRefresh() {
refreshJob?.cancel()
refreshJob = viewModelScope.launch {
while (true) {
delay(REFRESH_POLL_INTERVAL_MILLIS)
if (tokenProvider.token == null) return@launch
if (!tokenProvider.isNearExpiry()) continue
when (val result = apiClient.refreshToken()) {
is ApiResult.Success -> tokenProvider.setSession(result.data)
is ApiResult.Error -> if (result.code == 401) {
// ApiClient already cleared the stored token on the 401; reflect that
// here so the UI falls back to full re-authentication.
_state.update { it.copy(isAuthenticated = false) }
return@launch
}
ApiResult.NetworkUnavailable -> Unit // transient — retry on the next tick
}
}
}
}

/** Called from MainActivity.onStop — records when the app left the foreground. */
fun onAppBackgrounded(now: Long = System.currentTimeMillis()) {
if (_state.value.isAuthenticated) backgroundedAtMillis = now
Expand All @@ -98,6 +132,7 @@ class AuthViewModel @Inject constructor(
consecutiveFailures = 0
cooldownJob?.cancel()
_state.update { it.copy(isAuthenticated = true, isLoading = false, cooldownRemainingSeconds = 0) }
startScheduledRefresh()
}
.onFailure { e -> handleAuthFailure(e) }
}
Expand All @@ -123,11 +158,55 @@ class AuthViewModel @Inject constructor(
// PasskeyService.register already stores the session token returned by the
// backend, so there's no need to run a second sign-in ceremony here.
passkeyService.register(activity, username)
.onSuccess { _state.update { it.copy(isAuthenticated = true, isLoading = false) } }
.onSuccess {
_state.update { it.copy(isAuthenticated = true, isLoading = false) }
startScheduledRefresh()
}
.onFailure { e -> handleAuthFailure(e) }
}

// ── Account recovery ("lost your device?") — mirrors iOS AuthStore.recoverAccess (#211) ──

fun sendRecoveryCode(username: String) = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
when (val result = apiClient.initiateRecovery(RecoveryInitiateRequest(username))) {
is ApiResult.Success -> _state.update { it.copy(isLoading = false, recoveryToken = result.data.recoveryToken) }
is ApiResult.Error -> _state.update { it.copy(isLoading = false, error = result.message) }
ApiResult.NetworkUnavailable -> _state.update { it.copy(isLoading = false, error = "No network") }
}
}

fun finishRecovery(activity: Activity, username: String) = viewModelScope.launch {
val recoveryToken = _state.value.recoveryToken ?: return@launch
_state.update { it.copy(isLoading = true, error = null) }
passkeyService.recoverAccount(activity, username, recoveryToken)
.onSuccess {
_state.update { it.copy(isAuthenticated = true, isLoading = false, recoveryToken = null) }
startScheduledRefresh()
}
.onFailure { e ->
// A rejected recovery token/proof (401) can't be retried as-is — drop back to
// the "send code" step instead of leaving the user stuck on a dead-end error.
val recoveryRejected = (e as? ApiCallFailedException)?.code == 401
val message = if (e is PasskeyException) e.message ?: "Recovery failed. Please try again."
else ApiErrorMapper.friendlyMessage(e)
_state.update {
it.copy(
isLoading = false,
error = message,
recoveryToken = if (recoveryRejected) null else it.recoveryToken
)
}
}
}

fun clearRecovery() {
_state.update { it.copy(recoveryToken = null, error = null) }
}

fun signOut() = viewModelScope.launch {
refreshJob?.cancel()
refreshJob = null
// Unregister before clearing the auth token — ApiClient.bearerAuth() reads
// tokenProvider.token when building the request, so clearing first would send
// the delete unauthenticated. Best-effort: sign-out proceeds locally either way.
Expand Down Expand Up @@ -157,6 +236,53 @@ class AuthViewModel @Inject constructor(
private const val COOLDOWN_FAILURE_THRESHOLD = 3
private const val COOLDOWN_BASE_SECONDS = 2
private const val COOLDOWN_MAX_SECONDS = 60
// #209: matches TokenProvider.isNearExpiry's default 60s threshold — checking every
// 30s guarantees at least one check lands inside that window before expiry.
private const val REFRESH_POLL_INTERVAL_MILLIS = 30_000L
}
}

// --- Sessions ViewModel (#208) ---

data class SessionsUiState(
val sessions: List<Session> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)

@HiltViewModel
class SessionsViewModel @Inject constructor(
private val apiClient: ApiClient
) : ViewModel() {

private val _state = MutableStateFlow(SessionsUiState())
val state = _state.asStateFlow()

fun load() = viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
when (val result = apiClient.listSessions()) {
is ApiResult.Success -> _state.update { it.copy(sessions = result.data, isLoading = false) }
is ApiResult.Error -> _state.update { it.copy(isLoading = false, error = result.message) }
ApiResult.NetworkUnavailable -> _state.update { it.copy(isLoading = false, error = "No network") }
}
}

// Caller (SessionsScreen) is responsible for the biometric prompt before invoking this —
// mirrors VaultListScreen's check-in confirmation pattern.
fun revoke(session: Session) = viewModelScope.launch {
when (val result = apiClient.revokeSession(session.id)) {
is ApiResult.Success -> _state.update { it.copy(sessions = it.sessions.filter { s -> s.id != session.id }) }
is ApiResult.Error -> _state.update { it.copy(error = result.message) }
ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") }
}
}

fun revokeAllOthers() = viewModelScope.launch {
when (val result = apiClient.revokeOtherSessions()) {
is ApiResult.Success -> load()
is ApiResult.Error -> _state.update { it.copy(error = result.message) }
ApiResult.NetworkUnavailable -> _state.update { it.copy(error = "No network") }
}
}
}

Expand Down
Loading