Skip to content
Open
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 app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
161 changes: 161 additions & 0 deletions app/src/main/java/com/matedroid/data/repository/ServerHealthMonitor.kt
Original file line number Diff line number Diff line change
@@ -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> = _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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -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<Unit> {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/com/matedroid/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,4 +21,13 @@ object AppModule {
): SettingsDataStore {
return SettingsDataStore(context)
}

@Provides
@Singleton
fun provideServerHealthMonitor(
apiFactory: TeslamateApiFactory,
settingsDataStore: SettingsDataStore
): ServerHealthMonitor {
return ServerHealthMonitor(apiFactory, settingsDataStore)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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())
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading