From 5ecc96eeb1d0a442f1a4a2c1ab638b0af68468ad Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 8 Jul 2026 14:06:45 +0200 Subject: [PATCH 1/2] fix: report TLS/certificate failures distinctly instead of "check your connection" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSLException is a subtype of IOException, so a TLS/certificate handshake failure was being caught by the generic IOException branch and mapped to ErrorCodes.NETWORK -> "Couldn't reach that address. Check the URL and your connection." That sends the user chasing DNS/Wi-Fi when the real cause is the server's certificate (e.g. a chain that terminates in an expired root, which Android's system trust store rejects even though a browser with its own root store reaches the same host fine). - Add ErrorCodes.TLS and catch SSLException BEFORE IOException in apiCall/apiResponse (ApiResult) and in bookClubCall/bookClubCallUnit (BookClubResult). - Map the new code to a certificate-specific message in the onboarding and login view models, with strings for all four locales (en/it/de/fr). Now the onboarding "Connect your library" step tells the user it's a certificate/TLS problem and to check the server certificate — no app-side trust is relaxed, the bad cert is still rejected, it's just reported accurately. --- .../java/com/pinakes/app/data/network/ApiResult.kt | 11 +++++++++++ .../com/pinakes/app/data/network/BookClubResult.kt | 5 +++++ .../pinakes/app/ui/screens/login/LoginViewModel.kt | 1 + .../app/ui/screens/onboarding/OnboardingViewModel.kt | 1 + i18n/de.json | 2 ++ i18n/en.json | 2 ++ i18n/fr.json | 2 ++ i18n/it.json | 2 ++ 8 files changed, 26 insertions(+) diff --git a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt index 2044bf8..93c15d3 100644 --- a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt +++ b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt @@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json import retrofit2.HttpException import retrofit2.Response import java.io.IOException +import javax.net.ssl.SSLException /** * A normalized result for every API call. Maps the {data, meta, error} envelope and @@ -40,6 +41,10 @@ object ErrorCodes { const val VALIDATION = "validation" // 400 / 422 const val SERVER_ERROR = "server_error" // 5xx const val NETWORK = "network" // no connectivity / timeout + // TLS/certificate failure (SSLException — a subtype of IOException). Kept distinct + // from NETWORK so the UI can say "the server's certificate isn't trusted" instead of + // sending the user to chase DNS/connectivity when a browser reaches the same host fine. + const val TLS = "tls" const val UNKNOWN = "unknown" } @@ -73,6 +78,10 @@ suspend fun apiCall(block: suspend () -> Envelope): ApiResult = try { } } catch (e: HttpException) { e.toFailure() +} catch (e: SSLException) { + // SSLException extends IOException — catch it first so a TLS/certificate failure is + // reported as TLS (not the generic "check your connection" network message). + ApiResult.Failure(ErrorCodes.TLS, e.message ?: "TLS error", 0) } catch (e: IOException) { ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error", 0) } catch (e: Throwable) { @@ -97,6 +106,8 @@ suspend fun apiResponse(block: suspend () -> Response>): Pair bookClubCall(block: suspend () -> BookClubEnvelope): ApiResul } } catch (e: HttpException) { e.toFailure() +} catch (e: SSLException) { + ApiResult.Failure(ErrorCodes.TLS, e.message ?: "TLS error", 0) } catch (e: IOException) { ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error", 0) } catch (e: Throwable) { @@ -53,6 +56,8 @@ suspend fun bookClubCallUnit(block: suspend () -> BookClubEnvelope): ApiRe } } catch (e: HttpException) { e.toFailure() +} catch (e: SSLException) { + ApiResult.Failure(ErrorCodes.TLS, e.message ?: "TLS error", 0) } catch (e: IOException) { ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error", 0) } catch (e: Throwable) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt index 2be5e22..e35424b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt @@ -85,6 +85,7 @@ class LoginViewModel @Inject constructor( else state.copy(error = null, errorRes = R.string.login_error_rate_limited, errorArg = null) } ErrorCodes.NETWORK -> state.copy(error = null, errorRes = R.string.login_error_network, errorArg = null) + ErrorCodes.TLS -> state.copy(error = null, errorRes = R.string.login_error_tls, errorArg = null) ErrorCodes.FORBIDDEN -> state.copy(error = null, errorRes = R.string.login_error_forbidden, errorArg = null) else -> if (failure.message.isNotBlank()) state.copy(error = failure.message, errorRes = null, errorArg = null) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt index 43ad8a6..7394dba 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -71,6 +71,7 @@ class OnboardingViewModel @Inject constructor(private val auth: AuthRepository) private fun applyError(state: OnboardingUiState, failure: ApiResult.Failure): OnboardingUiState = when (failure.code) { "network" -> state.copy(error = null, errorRes = R.string.onboarding_error_network) + "tls" -> state.copy(error = null, errorRes = R.string.onboarding_error_tls) "not_found" -> state.copy(error = null, errorRes = R.string.onboarding_error_not_found) else -> if (failure.message.isNotBlank()) state.copy(error = failure.message, errorRes = null) diff --git a/i18n/de.json b/i18n/de.json index 04a4706..813f0ba 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -309,10 +309,12 @@ "login_error_rate_limited_seconds": "Zu viele Versuche. Versuche es in %1$ds erneut.", "login_error_rate_limited": "Zu viele Versuche. Bitte warte und versuche es erneut.", "login_error_network": "Netzwerkfehler. Überprüfe deine Verbindung.", + "login_error_tls": "Sichere Verbindung (TLS) fehlgeschlagen — das Zertifikat des Servers ist nicht vertrauenswürdig.", "login_error_forbidden": "Dieses Konto darf sich hier nicht anmelden.", "login_error_generic": "Anmeldung fehlgeschlagen. Bitte versuche es erneut.", "onboarding_error_empty": "Gib die Webadresse deiner Bibliothek ein.", "onboarding_error_network": "Diese Adresse konnte nicht erreicht werden. Überprüfe URL und Verbindung.", + "onboarding_error_tls": "Es konnte keine sichere HTTPS-Verbindung hergestellt werden — das TLS-Zertifikat des Servers ist nicht vertrauenswürdig. Prüfe das Zertifikat auf deinem Server.", "onboarding_error_not_found": "Das sieht nicht nach einer Pinakes-Bibliothek aus (keine API gefunden).", "onboarding_error_generic": "Verbindung zu dieser Bibliothek nicht möglich.", "availability_reserved": "Vorgemerkt", diff --git a/i18n/en.json b/i18n/en.json index 5b80242..ec95117 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -309,10 +309,12 @@ "login_error_rate_limited_seconds": "Too many attempts. Try again in %1$ds.", "login_error_rate_limited": "Too many attempts. Please wait and try again.", "login_error_network": "Network error. Check your connection.", + "login_error_tls": "Secure connection (TLS) failed — the server's certificate isn't trusted.", "login_error_forbidden": "This account isn't allowed to sign in here.", "login_error_generic": "Sign-in failed. Please try again.", "onboarding_error_empty": "Enter your library's web address.", "onboarding_error_network": "Couldn't reach that address. Check the URL and your connection.", + "onboarding_error_tls": "Couldn't establish a secure HTTPS connection — the server's TLS certificate isn't trusted. Check the certificate on your server.", "onboarding_error_not_found": "This doesn't look like a Pinakes library (no API found there).", "onboarding_error_generic": "Couldn't connect to that library.", "availability_reserved": "Reserved", diff --git a/i18n/fr.json b/i18n/fr.json index f229e67..67c542e 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -309,10 +309,12 @@ "login_error_rate_limited_seconds": "Trop de tentatives. Réessayez dans %1$ds.", "login_error_rate_limited": "Trop de tentatives. Veuillez patienter et réessayer.", "login_error_network": "Erreur réseau. Vérifiez votre connexion.", + "login_error_tls": "Échec de la connexion sécurisée (TLS) — le certificat du serveur n'est pas approuvé.", "login_error_forbidden": "Ce compte n'est pas autorisé à se connecter ici.", "login_error_generic": "Échec de la connexion. Veuillez réessayer.", "onboarding_error_empty": "Saisissez l'adresse web de votre bibliothèque.", "onboarding_error_network": "Impossible d'atteindre cette adresse. Vérifiez l'URL et votre connexion.", + "onboarding_error_tls": "Impossible d'établir une connexion HTTPS sécurisée — le certificat TLS du serveur n'est pas approuvé. Vérifiez le certificat sur votre serveur.", "onboarding_error_not_found": "Cela ne ressemble pas à une bibliothèque Pinakes (aucune API trouvée).", "onboarding_error_generic": "Impossible de se connecter à cette bibliothèque.", "availability_reserved": "Réservé", diff --git a/i18n/it.json b/i18n/it.json index 621add7..d75794b 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -309,10 +309,12 @@ "login_error_rate_limited_seconds": "Troppi tentativi. Riprova tra %1$ds.", "login_error_rate_limited": "Troppi tentativi. Attendi e riprova.", "login_error_network": "Errore di rete. Controlla la connessione.", + "login_error_tls": "Connessione sicura (TLS) fallita — il certificato del server non è attendibile.", "login_error_forbidden": "Questo account non può accedere qui.", "login_error_generic": "Accesso non riuscito. Riprova.", "onboarding_error_empty": "Inserisci l'indirizzo web della tua biblioteca.", "onboarding_error_network": "Impossibile raggiungere quell'indirizzo. Controlla l'URL e la connessione.", + "onboarding_error_tls": "Impossibile stabilire una connessione HTTPS sicura — il certificato TLS del server non è attendibile. Controlla il certificato sul server.", "onboarding_error_not_found": "Questa non sembra una biblioteca Pinakes (nessuna API trovata).", "onboarding_error_generic": "Impossibile connettersi a quella biblioteca.", "availability_reserved": "Prenotato", From feda639c7c5a6a34cca9add182ba76c4449b1506 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 8 Jul 2026 14:25:34 +0200 Subject: [PATCH 2/2] refactor(onboarding): match error codes with ErrorCodes constants, not string literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align applyError() with LoginViewModel — use ErrorCodes.NETWORK/TLS/NOT_FOUND instead of "network"/"tls"/"not_found" so a typo is a compile error, not a silently unmatched branch. --- .../app/ui/screens/onboarding/OnboardingViewModel.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt index 7394dba..c5ddabd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -3,6 +3,7 @@ package com.pinakes.app.ui.screens.onboarding import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.HealthDiscovery import com.pinakes.app.R @@ -70,9 +71,9 @@ class OnboardingViewModel @Inject constructor(private val auth: AuthRepository) } private fun applyError(state: OnboardingUiState, failure: ApiResult.Failure): OnboardingUiState = when (failure.code) { - "network" -> state.copy(error = null, errorRes = R.string.onboarding_error_network) - "tls" -> state.copy(error = null, errorRes = R.string.onboarding_error_tls) - "not_found" -> state.copy(error = null, errorRes = R.string.onboarding_error_not_found) + ErrorCodes.NETWORK -> state.copy(error = null, errorRes = R.string.onboarding_error_network) + ErrorCodes.TLS -> state.copy(error = null, errorRes = R.string.onboarding_error_tls) + ErrorCodes.NOT_FOUND -> state.copy(error = null, errorRes = R.string.onboarding_error_not_found) else -> if (failure.message.isNotBlank()) state.copy(error = failure.message, errorRes = null) else state.copy(error = null, errorRes = R.string.onboarding_error_generic)