From 4c2becd93263037f6a4c96de3e6ef7ea9e164315 Mon Sep 17 00:00:00 2001 From: Davide Ferrari Date: Mon, 19 Jan 2026 22:06:14 +0100 Subject: [PATCH] feat: add background server health monitoring Adds a ServerHealthMonitor that periodically checks the availability of primary and secondary servers. This allows the app to proactively switch to the available server without waiting for connection timeouts. - Runs health checks every 10 seconds when app is in foreground - Uses ProcessLifecycleOwner to detect app foreground/background state - TeslamateRepository now uses health status to choose server order - Settings changes trigger immediate health check - Added lifecycle-process dependency Co-Authored-By: Claude Opus 4.5 --- app/build.gradle.kts | 1 + .../data/repository/ServerHealthMonitor.kt | 161 ++++++++++++++++++ .../data/repository/TeslamateRepository.kt | 67 +++++--- .../main/java/com/matedroid/di/AppModule.kt | 10 ++ .../ui/screens/settings/SettingsViewModel.kt | 10 +- .../screens/settings/SettingsViewModelTest.kt | 15 +- gradle/libs.versions.toml | 1 + 7 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 app/src/main/java/com/matedroid/data/repository/ServerHealthMonitor.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 786b9672..b5b31df8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.activity.compose) // Compose diff --git a/app/src/main/java/com/matedroid/data/repository/ServerHealthMonitor.kt b/app/src/main/java/com/matedroid/data/repository/ServerHealthMonitor.kt new file mode 100644 index 00000000..60548656 --- /dev/null +++ b/app/src/main/java/com/matedroid/data/repository/ServerHealthMonitor.kt @@ -0,0 +1,161 @@ +package com.matedroid.data.repository + +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.matedroid.data.local.SettingsDataStore +import com.matedroid.di.TeslamateApiFactory +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +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.flow.first +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Represents the current server availability status. + */ +enum class ServerPreference { + /** Primary server is available, use it first */ + PRIMARY, + /** Primary server is unavailable, use secondary first */ + SECONDARY_ONLY, + /** No servers are available or configured */ + NONE, + /** Health check hasn't completed yet */ + UNKNOWN +} + +/** + * Monitors the health of configured Teslamate servers and determines which one to use. + * + * This monitor runs periodic health checks (ping) against both primary and secondary servers + * to proactively determine availability. This avoids waiting for connection timeouts when + * switching between network contexts (e.g., VPN on/off). + * + * The monitor only runs when: + * - The app is in the foreground + * - A secondary server is configured (otherwise there's no need for health checks) + */ +@Singleton +class ServerHealthMonitor @Inject constructor( + private val apiFactory: TeslamateApiFactory, + private val settingsDataStore: SettingsDataStore +) : DefaultLifecycleObserver { + + companion object { + private const val TAG = "ServerHealthMonitor" + private const val HEALTH_CHECK_INTERVAL_MS = 10_000L // 10 seconds + } + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var monitorJob: Job? = null + private var isAppInForeground = false + + private val _serverPreference = MutableStateFlow(ServerPreference.UNKNOWN) + val serverPreference: StateFlow = _serverPreference.asStateFlow() + + init { + // Register for app lifecycle events + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + } + + override fun onStart(owner: LifecycleOwner) { + isAppInForeground = true + startMonitoring() + } + + override fun onStop(owner: LifecycleOwner) { + isAppInForeground = false + stopMonitoring() + } + + private fun startMonitoring() { + if (monitorJob?.isActive == true) return + + monitorJob = scope.launch { + Log.d(TAG, "Starting health monitoring") + + // Run first check immediately + checkServersHealth() + + // Then run periodically + while (isAppInForeground) { + delay(HEALTH_CHECK_INTERVAL_MS) + if (isAppInForeground) { + checkServersHealth() + } + } + } + } + + private fun stopMonitoring() { + Log.d(TAG, "Stopping health monitoring") + monitorJob?.cancel() + monitorJob = null + } + + /** + * Triggers an immediate health check. + * Useful when settings change or network connectivity changes. + */ + fun checkNow() { + scope.launch { + checkServersHealth() + } + } + + private suspend fun checkServersHealth() { + val settings = settingsDataStore.settings.first() + + // If no primary server is configured, nothing to do + if (settings.serverUrl.isBlank()) { + _serverPreference.value = ServerPreference.NONE + return + } + + // If no secondary server is configured, always use primary (no need for health checks) + if (!settings.hasSecondaryServer) { + _serverPreference.value = ServerPreference.PRIMARY + return + } + + // Check primary server availability + val primaryAvailable = pingServer(settings.serverUrl) + + val newPreference = if (primaryAvailable) { + ServerPreference.PRIMARY + } else { + // Primary failed, check if secondary is available + val secondaryAvailable = pingServer(settings.secondaryServerUrl) + if (secondaryAvailable) { + ServerPreference.SECONDARY_ONLY + } else { + ServerPreference.NONE + } + } + + if (_serverPreference.value != newPreference) { + Log.d(TAG, "Server preference changed: ${_serverPreference.value} -> $newPreference") + } + _serverPreference.value = newPreference + } + + private suspend fun pingServer(url: String): Boolean { + return try { + val api = apiFactory.create(url) + val response = api.ping() + response.isSuccessful + } catch (e: Exception) { + Log.d(TAG, "Ping failed for $url: ${e.message}") + false + } + } +} diff --git a/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt b/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt index dfe48311..e1983c59 100644 --- a/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt +++ b/app/src/main/java/com/matedroid/data/repository/TeslamateRepository.kt @@ -47,7 +47,8 @@ private fun Throwable.isNetworkError(): Boolean { @Singleton class TeslamateRepository @Inject constructor( private val apiFactory: TeslamateApiFactory, - private val settingsDataStore: SettingsDataStore + private val settingsDataStore: SettingsDataStore, + private val serverHealthMonitor: ServerHealthMonitor ) { companion object { private const val TAG = "TeslamateRepository" @@ -61,7 +62,12 @@ class TeslamateRepository @Inject constructor( } /** - * Executes an API call with automatic fallback to the secondary server if configured. + * Executes an API call with automatic fallback between primary and secondary servers. + * + * Uses the ServerHealthMonitor to determine which server to try first: + * - If primary is known to be available, try it first + * - If only secondary is available, try it first + * - Falls back to the other server on network errors * * The fallback is triggered only for network-level errors (timeout, connection refused, * DNS failure, SSL errors). HTTP errors (4xx, 5xx) do NOT trigger fallback because @@ -79,16 +85,29 @@ class TeslamateRepository @Inject constructor( return ApiResult.Error("Server not configured") } - // Try primary server first - val primaryApi = getApiForUrl(settings.serverUrl) + // Determine server order based on health monitor + val preference = serverHealthMonitor.serverPreference.value + val (firstUrl, secondUrl) = when (preference) { + ServerPreference.SECONDARY_ONLY -> { + Log.d(TAG, "Using secondary server first (primary known unavailable)") + settings.secondaryServerUrl to settings.serverUrl + } + else -> { + // PRIMARY, UNKNOWN, or NONE - try primary first + settings.serverUrl to settings.secondaryServerUrl + } + } + + // Try first server + val firstApi = getApiForUrl(firstUrl) ?: return ApiResult.Error("Server not configured") - val primaryResult = try { - apiCall(primaryApi) + val firstResult = try { + apiCall(firstApi) } catch (e: Exception) { if (e.isNetworkError() && settings.hasSecondaryServer) { - Log.d(TAG, "Primary server failed with network error, trying secondary: ${e.message}") - null // Will try secondary + Log.d(TAG, "First server ($firstUrl) failed with network error, trying fallback: ${e.message}") + null // Will try fallback } else { // Not a network error or no secondary server, return the error return when (e) { @@ -99,39 +118,39 @@ class TeslamateRepository @Inject constructor( } } - // If primary succeeded or returned an HTTP error, return it - if (primaryResult != null) { + // If first server succeeded or returned an HTTP error, return it + if (firstResult != null) { // Only fallback on network errors, not on HTTP errors - if (primaryResult is ApiResult.Success) { - return primaryResult + if (firstResult is ApiResult.Success) { + return firstResult } // For HTTP errors, don't fallback - the server is reachable - if (primaryResult is ApiResult.Error && primaryResult.code != null) { - return primaryResult + if (firstResult is ApiResult.Error && firstResult.code != null) { + return firstResult } } - // Try secondary server if available - if (settings.hasSecondaryServer) { - Log.d(TAG, "Trying secondary server: ${settings.secondaryServerUrl}") - val secondaryApi = getApiForUrl(settings.secondaryServerUrl) - ?: return primaryResult ?: ApiResult.Error("Secondary server not configured") + // Try fallback server if available + if (settings.hasSecondaryServer && secondUrl.isNotBlank()) { + Log.d(TAG, "Trying fallback server: $secondUrl") + val secondApi = getApiForUrl(secondUrl) + ?: return firstResult ?: ApiResult.Error("Fallback server not configured") return try { - apiCall(secondaryApi) + apiCall(secondApi) } catch (e: Exception) { - Log.d(TAG, "Secondary server also failed: ${e.message}") + Log.d(TAG, "Fallback server also failed: ${e.message}") // Both servers failed, return a combined error message when (e) { is javax.net.ssl.SSLHandshakeException -> - ApiResult.Error("Both servers failed. SSL certificate error on secondary server.") + ApiResult.Error("Both servers failed. SSL certificate error on fallback server.") else -> ApiResult.Error("Both servers unreachable: ${e.message}") } } } - // No secondary server, return the primary error - return primaryResult ?: ApiResult.Error("Connection failed") + // No fallback server, return the first server's error + return firstResult ?: ApiResult.Error("Connection failed") } suspend fun testConnection(serverUrl: String, acceptInvalidCerts: Boolean = false): ApiResult { diff --git a/app/src/main/java/com/matedroid/di/AppModule.kt b/app/src/main/java/com/matedroid/di/AppModule.kt index 947d8208..a7ccf433 100644 --- a/app/src/main/java/com/matedroid/di/AppModule.kt +++ b/app/src/main/java/com/matedroid/di/AppModule.kt @@ -2,6 +2,7 @@ package com.matedroid.di import android.content.Context import com.matedroid.data.local.SettingsDataStore +import com.matedroid.data.repository.ServerHealthMonitor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -20,4 +21,13 @@ object AppModule { ): SettingsDataStore { return SettingsDataStore(context) } + + @Provides + @Singleton + fun provideServerHealthMonitor( + apiFactory: TeslamateApiFactory, + settingsDataStore: SettingsDataStore + ): ServerHealthMonitor { + return ServerHealthMonitor(apiFactory, settingsDataStore) + } } diff --git a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt index a6304769..e1e67931 100644 --- a/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/matedroid/ui/screens/settings/SettingsViewModel.kt @@ -11,9 +11,11 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import com.matedroid.data.local.SettingsDataStore import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.ServerHealthMonitor import com.matedroid.data.repository.TeslamateRepository import com.matedroid.data.sync.DataSyncWorker import com.matedroid.data.sync.SyncManager +import com.matedroid.di.TeslamateApiFactory import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -69,7 +71,9 @@ class SettingsViewModel @Inject constructor( @ApplicationContext private val context: Context, private val settingsDataStore: SettingsDataStore, private val repository: TeslamateRepository, - private val syncManager: SyncManager + private val syncManager: SyncManager, + private val serverHealthMonitor: ServerHealthMonitor, + private val apiFactory: TeslamateApiFactory ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -239,6 +243,10 @@ class SettingsViewModel @Inject constructor( currencyCode = _uiState.value.currencyCode ) + // Invalidate API cache and refresh server health status + apiFactory.invalidateCache() + serverHealthMonitor.checkNow() + // Trigger sync after settings are saved (handles first-time setup) triggerImmediateSync() diff --git a/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt index 44c7d392..25a89b94 100644 --- a/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/matedroid/ui/screens/settings/SettingsViewModelTest.kt @@ -5,8 +5,10 @@ import androidx.work.WorkManager import com.matedroid.data.local.AppSettings import com.matedroid.data.local.SettingsDataStore import com.matedroid.data.repository.ApiResult +import com.matedroid.data.repository.ServerHealthMonitor import com.matedroid.data.repository.TeslamateRepository import com.matedroid.data.sync.SyncManager +import com.matedroid.di.TeslamateApiFactory import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -36,6 +38,8 @@ class SettingsViewModelTest { private lateinit var settingsDataStore: SettingsDataStore private lateinit var repository: TeslamateRepository private lateinit var syncManager: SyncManager + private lateinit var serverHealthMonitor: ServerHealthMonitor + private lateinit var apiFactory: TeslamateApiFactory private lateinit var workManager: WorkManager private lateinit var viewModel: SettingsViewModel @@ -46,6 +50,8 @@ class SettingsViewModelTest { settingsDataStore = mockk() repository = mockk() syncManager = mockk() + serverHealthMonitor = mockk(relaxed = true) + apiFactory = mockk(relaxed = true) workManager = mockk(relaxed = true) every { settingsDataStore.settings } returns flowOf(AppSettings()) @@ -61,7 +67,14 @@ class SettingsViewModelTest { } private fun createViewModel(): SettingsViewModel { - return SettingsViewModel(context, settingsDataStore, repository, syncManager) + return SettingsViewModel( + context, + settingsDataStore, + repository, + syncManager, + serverHealthMonitor, + apiFactory + ) } @Test diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a29de050..7666e6eb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ hiltWork = "1.2.0" androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleRuntimeKtx" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } # Compose