From e517720b039b4020cff3546f0beec7abe8042369 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Thu, 4 Jun 2026 22:15:44 +0200 Subject: [PATCH] Implement network retry mechanism across repositories and add connectivity monitoring - Introduced `retryOnNetworkFailure` function to handle network-related exceptions and retry requests. - Updated `GalleryRepository`, `HomeRepository`, `LoginRepository`, `MannschaftenRepository`, `NewsletterRepository`, `PasskeyRepository`, `ProfileRepository`, `PublicPagesRepository`, `SpielplanRepository`, `TermineRepository`, and `TrainingRepository` to use the new retry mechanism. - Added `ConnectivityMonitor` to track internet connectivity status and notify UI components. - Enhanced `NavigationViewModel`, `CmsViewModel`, `MembersViewModel`, and `MemberAreaViewModel` to reload data when connectivity is restored. - Bumped app version to 0.9.16. --- .../harheimertc/data/ConnectivityMonitor.kt | 49 ++++++ .../repositories/GalleryRepository.kt | 28 ++-- .../repositories/HomeRepository.kt | 140 ++++++++++-------- .../repositories/LoginRepository.kt | 22 +-- .../repositories/MannschaftenRepository.kt | 16 +- .../harheimertc/repositories/NetworkRetry.kt | 33 +++++ .../repositories/NewsletterRepository.kt | 16 +- .../repositories/PasskeyRepository.kt | 110 +++++++------- .../repositories/ProfileRepository.kt | 16 +- .../repositories/PublicPagesRepository.kt | 66 +++++---- .../repositories/SpielplanRepository.kt | 24 +-- .../repositories/TermineRepository.kt | 8 +- .../repositories/TrainingRepository.kt | 8 +- .../de/harheimertc/ui/navigation/NavGraph.kt | 16 ++ .../ui/navigation/NavigationViewModel.kt | 15 ++ .../ui/screens/cms/CmsViewModels.kt | 11 ++ .../memberarea/MemberAreaDetailViewModels.kt | 31 ++++ .../screens/memberarea/MemberAreaViewModel.kt | 11 ++ android-app/gradle.properties | 4 +- 19 files changed, 419 insertions(+), 205 deletions(-) create mode 100644 android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt create mode 100644 android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt diff --git a/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt new file mode 100644 index 0000000..ee5652b --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/data/ConnectivityMonitor.kt @@ -0,0 +1,49 @@ +package de.harheimertc.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ConnectivityMonitor @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _online = MutableStateFlow(hasInternetAccess()) + val online: StateFlow = _online.asStateFlow() + + init { + scope.launch { poll() } + } + + private suspend fun poll() { + while (currentCoroutineContext().isActive) { + val current = hasInternetAccess() + if (_online.value != current) { + _online.value = current + } + delay(10_000L) + } + } + + private fun hasInternetAccess(): Boolean { + val manager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager + ?: return false + val network = manager.activeNetwork ?: return false + val capabilities = manager.getNetworkCapabilities(network) ?: return false + return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt index fe80945..75e4ba1 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/GalleryRepository.kt @@ -24,27 +24,27 @@ class GalleryRepository @Inject constructor( @param:ApplicationContext private val context: Context, ) { suspend fun hasPublicImages(): Result = runCatching { - val response = api.galerieList(page = 1, perPage = 1) - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body()?.images.orEmpty().isNotEmpty() + retryOnNetworkFailure { + val response = api.galerieList(page = 1, perPage = 1) + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body()?.images.orEmpty().isNotEmpty() + } } suspend fun fetchImages(page: Int = 1, perPage: Int = 60): Result { - return try { - val resp = api.galerieList(page = page, perPage = perPage) - if (resp.isSuccessful) { - val body = resp.body() - Result.success( + return runCatching { + retryOnNetworkFailure { + val resp = api.galerieList(page = page, perPage = perPage) + if (resp.isSuccessful) { + val body = resp.body() GalleryPage( images = body?.images.orEmpty().map { it.toGalleryImage() }, pagination = body?.pagination ?: GalleryPaginationDto(), - ), - ) - } else { - Result.failure(Exception("HTTP ${resp.code()}")) + ) + } else { + error("HTTP ${resp.code()}") + } } - } catch (e: Exception) { - Result.failure(e) } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt index 69927fc..61b338a 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/HomeRepository.kt @@ -31,19 +31,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { val diagnostics = mutableListOf() val termine = runCatching { - val response = api.termine() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/termine", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.termine() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/termine", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Termine konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.termine.orEmpty() } - response.body()?.termine.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.termine", error) if (diagnostics.none { it.contains("GET /api/termine") }) { @@ -58,19 +60,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val spielplanResponse = runCatching { - val response = api.spielplan() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/spielplan", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.spielplan() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/spielplan", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Spielplan konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body() } - response.body() }.onFailure { error -> captureLoadIssue("fetchHomeData.spielplan", error) if (diagnostics.none { it.contains("GET /api/spielplan") }) { @@ -86,19 +90,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { val spiele = spielplanResponse?.data.orEmpty() val news = runCatching { - val response = api.publicNews() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/news-public", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("News konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.publicNews() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/news-public", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("News konnten nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.news.orEmpty() } - response.body()?.news.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.news", error) if (diagnostics.none { it.contains("GET /api/news-public") }) { @@ -113,19 +119,21 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val homepageSections = runCatching { - val response = api.config() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/config", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/config", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Konfiguration konnte nicht geladen werden (HTTP ${response.code()}).") + } + response.body()?.homepage?.sections.orEmpty() } - response.body()?.homepage?.sections.orEmpty() }.onFailure { error -> captureLoadIssue("fetchHomeData.config", error) if (diagnostics.none { it.contains("GET /api/config") }) { @@ -140,20 +148,22 @@ class HomeRepository @Inject constructor(private val api: ApiService) { }.getOrDefault(emptyList()) val heroImageUrl = runCatching { - val response = api.heroImages() - if (!response.isSuccessful) { - val errorBody = response.errorBody()?.string().orEmpty() - diagnostics += buildDiagnostic( - endpoint = "GET /api/hero-images", - requestPayload = "none", - httpCode = response.code(), - responseBody = errorBody, - throwable = null, - ) - error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).") + retryOnNetworkFailure { + val response = api.heroImages() + if (!response.isSuccessful) { + val errorBody = response.errorBody()?.string().orEmpty() + diagnostics += buildDiagnostic( + endpoint = "GET /api/hero-images", + requestPayload = "none", + httpCode = response.code(), + responseBody = errorBody, + throwable = null, + ) + error("Hero-Bilder konnten nicht geladen werden (HTTP ${response.code()}).") + } + val variants = response.body()?.variants.orEmpty() + pickRandomHeroImage(variants) } - val variants = response.body()?.variants.orEmpty() - pickRandomHeroImage(variants) }.onFailure { error -> captureLoadIssue("fetchHomeData.heroImages", error) if (diagnostics.none { it.contains("GET /api/hero-images") }) { @@ -187,9 +197,11 @@ class HomeRepository @Inject constructor(private val api: ApiService) { } suspend fun fetchSpielplanForSeason(season: String): Result = runCatching { - val response = api.spielplan(season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.spielplan(season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body() ?: error("Leere Antwort") + } }.onFailure { error -> Sentry.withScope { scope -> scope.setTag("repository", "HomeRepository") diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt index 0bf3f73..e3933f8 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/LoginRepository.kt @@ -23,7 +23,7 @@ class LoginRepository @Inject constructor( suspend fun login(email: String, password: String): Result = runCatching { val endpoint = "api/auth/login" val requestPreview = "{email=\"${maskEmail(email)}\", client=\"android\", deviceName=\"Harheimer TC Android-App\"}" - val response = api.login(LoginRequest(email.trim(), password)) + val response = retryOnNetworkFailure { api.login(LoginRequest(email.trim(), password)) } if (!response.isSuccessful) { val body = response.errorBody()?.string().orEmpty() val serverMessage = extractServerMessage(body) @@ -71,11 +71,11 @@ class LoginRepository @Inject constructor( return@runCatching AuthStatusResponse() } - var response = api.authStatus() + var response = retryOnNetworkFailure { api.authStatus() } if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") var status = response.body() ?: AuthStatusResponse() if (!status.isLoggedIn && authRepository.getRefreshToken() != null && sessionRefresher.refreshAccessToken()) { - response = api.authStatus() + response = retryOnNetworkFailure { api.authStatus() } if (!response.isSuccessful) error("Status konnte nicht geprüft werden.") status = response.body() ?: AuthStatusResponse() } @@ -93,15 +93,19 @@ class LoginRepository @Inject constructor( } suspend fun resetPassword(email: String): Result = runCatching { - val response = api.resetPassword(ResetPasswordRequest(email.trim())) - if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.resetPassword(ResetPasswordRequest(email.trim())) + if (!response.isSuccessful) error("Anfrage konnte nicht gesendet werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun register(request: RegistrationRequest): Result = runCatching { - val response = api.register(request) - if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.register(request) + if (!response.isSuccessful) error("Registrierung fehlgeschlagen.") + response.body() ?: error("Leere Antwort") + } } private fun extractServerMessage(raw: String): String? { diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt index 91a33e6..5090df1 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/MannschaftenRepository.kt @@ -26,15 +26,19 @@ data class Mannschaft( @Singleton class MannschaftenRepository @Inject constructor(private val api: ApiService) { suspend fun fetchMannschaften(season: String? = null): Result> = runCatching { - val response = api.mannschaften(season) - if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()) + retryOnNetworkFailure { + val response = api.mannschaften(season) + if (!response.isSuccessful) error("Mannschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()) + } } suspend fun fetchSeasons(): Result = runCatching { - val response = api.mannschaftenSeasons() - if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") - response.body() ?: error("Saisons konnten nicht geladen werden.") + retryOnNetworkFailure { + val response = api.mannschaftenSeasons() + if (!response.isSuccessful) error("Saisons konnten nicht geladen werden.") + response.body() ?: error("Saisons konnten nicht geladen werden.") + } } private fun parseCsv(csv: String): List = csv.lineSequence() diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt new file mode 100644 index 0000000..523fab4 --- /dev/null +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NetworkRetry.kt @@ -0,0 +1,33 @@ +package de.harheimertc.repositories + +import java.net.ConnectException +import java.net.NoRouteToHostException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import javax.net.ssl.SSLException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay + +internal suspend fun retryOnNetworkFailure( + retryDelayMillis: Long = 10_000L, + block: suspend () -> T, +): T { + while (true) { + try { + return block() + } catch (error: Throwable) { + if (error is CancellationException) throw error + if (!error.isRetryableNetworkError()) throw error + delay(retryDelayMillis) + } + } +} + +private fun Throwable.isRetryableNetworkError(): Boolean = when (this) { + is UnknownHostException, + is ConnectException, + is NoRouteToHostException, + is SocketTimeoutException, + is SSLException -> true + else -> false +} \ No newline at end of file diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt index 9bada4d..35b6c26 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/NewsletterRepository.kt @@ -8,9 +8,11 @@ import javax.inject.Inject class NewsletterRepository @Inject constructor(private val api: ApiService) { suspend fun groups(): Result = runCatching { - val response = api.publicNewsletterGroups() - if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort vom Server.") + retryOnNetworkFailure { + val response = api.publicNewsletterGroups() + if (!response.isSuccessful) error("Newsletter konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort vom Server.") + } } suspend fun subscribe(groupId: String, email: String, name: String?): Result = runCatching { @@ -26,8 +28,10 @@ class NewsletterRepository @Inject constructor(private val api: ApiService) { } suspend fun confirm(token: String): Result = runCatching { - val response = api.confirmNewsletter(token) - if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") - response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + retryOnNetworkFailure { + val response = api.confirmNewsletter(token) + if (!response.isSuccessful) error("Newsletter-Bestätigung fehlgeschlagen.") + response.body() ?: AuthMessageResponse(success = true, message = "Newsletter-Anmeldung bestätigt.") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt index 11d5a57..5331e13 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PasskeyRepository.kt @@ -28,68 +28,74 @@ class PasskeyRepository @Inject constructor( private val authRepository: AuthRepository, ) { suspend fun login(context: Context, email: String?): Result = runCatching { - val optionsResponse = api.passkeyAuthenticationOptions( - PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), - ) - if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.") - val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") - ?: error("Der Server hat keine Passkey-Optionen geliefert.") + retryOnNetworkFailure { + val optionsResponse = api.passkeyAuthenticationOptions( + PasskeyAuthenticationOptionsRequest(email = email?.trim()?.takeIf(String::isNotBlank)), + ) + if (!optionsResponse.isSuccessful) error("Passkey-Anmeldung konnte nicht gestartet werden.") + val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") - val credentialManager = CredentialManager.create(context) - val credentialResponse = credentialManager.getCredential( - context = context, - request = GetCredentialRequest( - credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)), - ), - ) - val credential = credentialResponse.credential as? PublicKeyCredential - ?: error("Der ausgewählte Zugang ist kein Passkey.") + val credentialManager = CredentialManager.create(context) + val credentialResponse = credentialManager.getCredential( + context = context, + request = GetCredentialRequest( + credentialOptions = listOf(GetPublicKeyCredentialOption(optionsJson)), + ), + ) + val credential = credentialResponse.credential as? PublicKeyCredential + ?: error("Der ausgewählte Zugang ist kein Passkey.") - val response = api.passkeyLogin( - JSONObject() - .put("credential", JSONObject(credential.authenticationResponseJson)) - .put("client", "android") - .put("deviceName", "Harheimer TC Android-App") - .toString() - .toRequestBody(MediaTypes.json), - ) - if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.") - val body = response.body() ?: error("Leere Antwort") - val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) - ?: error("Der Server hat kein Zugriffstoken geliefert.") - authRepository.setSession(token, body.refreshToken, body.sessionId) - body + val response = api.passkeyLogin( + JSONObject() + .put("credential", JSONObject(credential.authenticationResponseJson)) + .put("client", "android") + .put("deviceName", "Harheimer TC Android-App") + .toString() + .toRequestBody(MediaTypes.json), + ) + if (!response.isSuccessful) error("Passkey-Anmeldung fehlgeschlagen.") + val body = response.body() ?: error("Leere Antwort") + val token = (body.accessToken ?: body.token)?.takeIf(String::isNotBlank) + ?: error("Der Server hat kein Zugriffstoken geliefert.") + authRepository.setSession(token, body.refreshToken, body.sessionId) + body + } }.recoverCredentialCancellation("Passkey-Anmeldung abgebrochen.") suspend fun list(): Result = runCatching { - val response = api.passkeys() - if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.passkeys() + if (!response.isSuccessful) error("Passkeys konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun add(context: Context, name: String = "Android-App"): Result = runCatching { - val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) - if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") - val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") - ?: error("Der Server hat keine Passkey-Optionen geliefert.") + retryOnNetworkFailure { + val optionsResponse = api.passkeyRegistrationOptions(PasskeyRegistrationOptionsRequest()) + if (!optionsResponse.isSuccessful) error("Passkey-Erstellung konnte nicht gestartet werden.") + val optionsJson = optionsResponse.body()?.string()?.extractJsonObject("options") + ?: error("Der Server hat keine Passkey-Optionen geliefert.") - val credentialManager = CredentialManager.create(context) - val credentialResponse = credentialManager.createCredential( - context = context, - request = CreatePublicKeyCredentialRequest(optionsJson), - ) as? CreatePublicKeyCredentialResponse - ?: error("Der erstellte Zugang ist kein Passkey.") + val credentialManager = CredentialManager.create(context) + val credentialResponse = credentialManager.createCredential( + context = context, + request = CreatePublicKeyCredentialRequest(optionsJson), + ) as? CreatePublicKeyCredentialResponse + ?: error("Der erstellte Zugang ist kein Passkey.") - val response = api.registerPasskey( - JSONObject() - .put("credential", JSONObject(credentialResponse.registrationResponseJson)) - .put("name", name) - .put("client", "android") - .toString() - .toRequestBody(MediaTypes.json), - ) - if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") - response.body() ?: error("Leere Antwort") + val response = api.registerPasskey( + JSONObject() + .put("credential", JSONObject(credentialResponse.registrationResponseJson)) + .put("name", name) + .put("client", "android") + .toString() + .toRequestBody(MediaTypes.json), + ) + if (!response.isSuccessful) error("Passkey konnte nicht hinzugefügt werden.") + response.body() ?: error("Leere Antwort") + } }.recoverCredentialCancellation("Passkey-Erstellung abgebrochen.") suspend fun remove(credentialId: String): Result = runCatching { diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt index d5ee5ca..9a4c497 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/ProfileRepository.kt @@ -9,14 +9,18 @@ import javax.inject.Singleton @Singleton class ProfileRepository @Inject constructor(private val api: ApiService) { suspend fun load(): Result = runCatching { - val response = api.profile() - if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.profile() + if (!response.isSuccessful) error("Profil konnte nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun save(request: ProfileUpdateRequest): Result = runCatching { - val response = api.updateProfile(request) - if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.updateProfile(request) + if (!response.isSuccessful) error("Profil konnte nicht gespeichert werden.") + response.body() ?: error("Leere Antwort") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt index 0b7c9e7..b33fa15 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/PublicPagesRepository.kt @@ -31,43 +31,49 @@ data class MeisterschaftResult( @Singleton class PublicPagesRepository @Inject constructor(private val api: ApiService) { suspend fun fetchConfig(): Result = runCatching { - val response = api.config() - if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) error("Inhalte konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } suspend fun fetchSpielsysteme(): Result> = runCatching { - val response = api.spielsysteme() - if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> - if (values.size < 8) return@mapNotNull null - Spielsystem( - name = values[0], - description = values[1], - teamSize = values[2], - category = values[3], - sequence = values[5], - gameCount = values[6], - features = values[7], - ) + retryOnNetworkFailure { + val response = api.spielsysteme() + if (!response.isSuccessful) error("Spielsysteme konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 8) return@mapNotNull null + Spielsystem( + name = values[0], + description = values[1], + teamSize = values[2], + category = values[3], + sequence = values[5], + gameCount = values[6], + features = values[7], + ) + } } } suspend fun fetchVereinsmeisterschaften(): Result> = runCatching { - val response = api.vereinsmeisterschaften() - if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") - parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> - if (values.size < 6) return@mapNotNull null - MeisterschaftResult( - year = values[0], - category = values[1], - rank = values[2], - playerOne = values[3], - playerTwo = values[4], - note = values[5], - imageOne = values.getOrElse(6) { "" }, - imageTwo = values.getOrElse(7) { "" }, - ) + retryOnNetworkFailure { + val response = api.vereinsmeisterschaften() + if (!response.isSuccessful) error("Vereinsmeisterschaften konnten nicht geladen werden.") + parseCsv(response.body()?.string().orEmpty()).drop(1).mapNotNull { values -> + if (values.size < 6) return@mapNotNull null + MeisterschaftResult( + year = values[0], + category = values[1], + rank = values[2], + playerOne = values[3], + playerTwo = values[4], + note = values[5], + imageOne = values.getOrElse(6) { "" }, + imageTwo = values.getOrElse(7) { "" }, + ) + } } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt index 851ef35..5eeeace 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/SpielplanRepository.kt @@ -9,18 +9,22 @@ import javax.inject.Singleton @Singleton class SpielplanRepository @Inject constructor(private val api: ApiService) { suspend fun fetchSpielplan(season: String? = null): Result = runCatching { - val response = api.spielplan(season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - val body = response.body() ?: error("Leere Antwort") - if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") - body + retryOnNetworkFailure { + val response = api.spielplan(season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Spielplan konnte nicht geladen werden.") + body + } } suspend fun fetchTeamTable(team: String, season: String? = null): Result = runCatching { - val response = api.spielplanTable(team, season) - if (!response.isSuccessful) error("HTTP ${response.code()}") - val body = response.body() ?: error("Leere Antwort") - if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.") - body + retryOnNetworkFailure { + val response = api.spielplanTable(team, season) + if (!response.isSuccessful) error("HTTP ${response.code()}") + val body = response.body() ?: error("Leere Antwort") + if (!body.success) error(body.message ?: "Tabelle konnte nicht geladen werden.") + body + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt index d2926cd..0fe2e02 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TermineRepository.kt @@ -8,8 +8,10 @@ import javax.inject.Singleton @Singleton class TermineRepository @Inject constructor(private val api: ApiService) { suspend fun fetchTermine(): Result> = runCatching { - val response = api.termine() - if (!response.isSuccessful) error("HTTP ${response.code()}") - response.body()?.termine.orEmpty() + retryOnNetworkFailure { + val response = api.termine() + if (!response.isSuccessful) error("HTTP ${response.code()}") + response.body()?.termine.orEmpty() + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt index 711b73b..cd9939e 100644 --- a/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt +++ b/android-app/app/src/main/java/de/harheimertc/repositories/TrainingRepository.kt @@ -8,8 +8,10 @@ import javax.inject.Singleton @Singleton class TrainingRepository @Inject constructor(private val api: ApiService) { suspend fun fetchConfig(): Result = runCatching { - val response = api.config() - if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") - response.body() ?: error("Leere Antwort") + retryOnNetworkFailure { + val response = api.config() + if (!response.isSuccessful) error("Trainingsinformationen konnten nicht geladen werden.") + response.body() ?: error("Leere Antwort") + } } } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt index bd03d63..55a7c30 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavGraph.kt @@ -2,10 +2,16 @@ package de.harheimertc.ui.navigation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState @@ -35,6 +41,16 @@ fun NavGraph( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val persistentNavigation = maxWidth >= 600.dp Column(modifier = Modifier.fillMaxSize()) { + navigationState.connectionNote?.let { message -> + Surface(color = Color(0xFFFFF4E5), modifier = Modifier.fillMaxWidth()) { + Text( + text = message, + color = Color(0xFF7C2D12), + style = MaterialTheme.typography.labelMedium, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) + } + } if (persistentNavigation) { AppNavigationHeader( selectedRoute = currentRoute, diff --git a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt index c1f1cc1..2cd8156 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/navigation/NavigationViewModel.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.navigation import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.repositories.AuthRepository import de.harheimertc.repositories.GalleryRepository import de.harheimertc.repositories.LoginRepository @@ -19,6 +20,7 @@ data class NavigationUiState( val hasGalleryImages: Boolean = false, val loggedIn: Boolean = false, val roles: Set = emptySet(), + val connectionNote: String? = null, ) { val isAdmin: Boolean get() = "admin" in roles val canAccessNewsletter: Boolean get() = roles.any { it in setOf("admin", "vorstand", "newsletter") } @@ -32,12 +34,22 @@ class NavigationViewModel @Inject constructor( private val galleryRepository: GalleryRepository, private val loginRepository: LoginRepository, private val authRepository: AuthRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(NavigationUiState()) val state: StateFlow = _state init { loadNavigationData() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + _state.value = _state.value.copy( + connectionNote = if (online) null else "Keine Verbindung. Die App versucht alle 10 Sekunden erneut zu laden.", + ) + wasOnline = online + } + } } fun loadNavigationData() { @@ -52,6 +64,7 @@ class NavigationViewModel @Inject constructor( hasGalleryImages = gallery.await(), loggedIn = hasStoredSession || status.isLoggedIn, roles = (status.roles + status.user?.roles.orEmpty()).toSet(), + connectionNote = null, ) } } @@ -63,6 +76,7 @@ class NavigationViewModel @Inject constructor( _state.value = _state.value.copy( loggedIn = hasStoredSession || status.isLoggedIn, roles = (status.roles + status.user?.roles.orEmpty()).toSet(), + connectionNote = _state.value.connectionNote, ) } } @@ -73,6 +87,7 @@ class NavigationViewModel @Inject constructor( _state.value = _state.value.copy( loggedIn = false, roles = emptySet(), + connectionNote = _state.value.connectionNote, ) onComplete() } diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt index 7d66132..328e728 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/cms/CmsViewModels.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.cms import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.CmsUserDto import de.harheimertc.data.ConfigResponse import de.harheimertc.data.ContactRequestDto @@ -43,12 +44,22 @@ data class CmsUiState( @HiltViewModel class CmsViewModel @Inject constructor( private val repository: CmsRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(CmsUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt index b42de74..3ad7b98 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaDetailViewModels.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.AuthStatusResponse import de.harheimertc.data.MemberDto import de.harheimertc.data.NewsDto @@ -24,12 +25,22 @@ data class MembersUiState( @HiltViewModel class MembersViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MembersUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun updateQuery(query: String) { @@ -87,12 +98,22 @@ data class MemberNewsUiState( @HiltViewModel class MemberNewsViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MemberNewsUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { @@ -118,12 +139,22 @@ data class QttrUiState( class QttrViewModel @Inject constructor( private val repository: MemberAreaRepository, private val loginRepository: LoginRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(QttrUiState()) val state: StateFlow = _state init { load() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + load() + } + wasOnline = online + } + } } fun load() { diff --git a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt index 26d4e8e..6fb9c3f 100644 --- a/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt +++ b/android-app/app/src/main/java/de/harheimertc/ui/screens/memberarea/MemberAreaViewModel.kt @@ -3,6 +3,7 @@ package de.harheimertc.ui.screens.memberarea import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import de.harheimertc.data.ConnectivityMonitor import de.harheimertc.data.BirthdayDto import de.harheimertc.repositories.MemberAreaRepository import kotlinx.coroutines.flow.MutableStateFlow @@ -19,12 +20,22 @@ data class MemberAreaUiState( @HiltViewModel class MemberAreaViewModel @Inject constructor( private val repository: MemberAreaRepository, + private val connectivityMonitor: ConnectivityMonitor, ) : ViewModel() { private val _state = MutableStateFlow(MemberAreaUiState()) val state: StateFlow = _state init { loadBirthdays() + viewModelScope.launch { + var wasOnline: Boolean? = null + connectivityMonitor.online.collect { online -> + if (online && wasOnline == false) { + loadBirthdays() + } + wasOnline = online + } + } } fun loadBirthdays() { diff --git a/android-app/gradle.properties b/android-app/gradle.properties index cd95f8c..34bd500 100644 --- a/android-app/gradle.properties +++ b/android-app/gradle.properties @@ -8,8 +8,8 @@ LOCAL_API_BASE_URL=https://harheimertc.tsschulz.de/ PRODUCTION_API_BASE_URL=https://harheimertc.de/ # Android app versioning for Play Store uploads -ANDROID_VERSION_CODE=20 -ANDROID_VERSION_NAME=0.9.15 +ANDROID_VERSION_CODE=21 +ANDROID_VERSION_NAME=0.9.16 # Temporary hotfix: disable R8 minification for release to avoid Retrofit generic signature stripping. RELEASE_MINIFY_ENABLED=false