diff --git a/app-android/app/src/main/AndroidManifest.xml b/app-android/app/src/main/AndroidManifest.xml index 946aa6e..01457e1 100644 --- a/app-android/app/src/main/AndroidManifest.xml +++ b/app-android/app/src/main/AndroidManifest.xml @@ -12,6 +12,7 @@ HttpClient(Android) { + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + encodeDefaults = false + }) + } + install(Logging) { level = LogLevel.INFO } + } + RestClientProfile.API -> HttpClient(Android) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + install(Logging) { level = LogLevel.INFO } + install(HttpTimeout) { + requestTimeoutMillis = 45_000 + connectTimeoutMillis = 15_000 + socketTimeoutMillis = 45_000 + } + install(Auth) { + bearer { + loadTokens { + val access = TokenManager.getAccessToken() + val refresh = TokenManager.getRefreshToken() + if (!access.isNullOrBlank() && !refresh.isNullOrBlank()) { + BearerTokens(access, refresh) + } else null + } + refreshTokens { + val refresh = oldTokens?.refreshToken + ?: TokenManager.getRefreshToken() + if (!refresh.isNullOrBlank()) { + val result = authService.refreshToken(refresh) + result.fold( + onSuccess = { tokenResponse -> + TokenManager.saveTokens( + tokenResponse.access_token, + tokenResponse.refresh_token, + TokenManager.getUserId() ?: "" + ) + BearerTokens( + tokenResponse.access_token, + tokenResponse.refresh_token + ) + }, + onFailure = { + TokenManager.clearTokens() + TokenManager.notifyTokenRefreshFailed() + null + } + ) + } else { + TokenManager.clearTokens() + TokenManager.notifyTokenRefreshFailed() + null + } + } + sendWithoutRequest { true } + } + } + } + } + } + + private fun createRealtimeClient(): HttpClient { + return HttpClient(OkHttp) { + install(WebSockets) + install(Logging) { level = LogLevel.INFO } + } + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/app/LifeEchoApp.kt b/app-android/app/src/main/java/com/huaga/life_echo/app/LifeEchoApp.kt new file mode 100644 index 0000000..b00f6b5 --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/app/LifeEchoApp.kt @@ -0,0 +1,23 @@ +package com.huaga.life_echo.app + +import android.app.Application +import com.huaga.life_echo.data.auth.TokenManager +import com.huaga.life_echo.ui.settings.AppSettings + +class LifeEchoApp : Application() { + + lateinit var container: AppContainer + private set + + override fun onCreate() { + super.onCreate() + TokenManager.initialize(this) + AppSettings.initialize(this) + container = AppContainer(this) + } + + override fun onTerminate() { + super.onTerminate() + container.close() + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/NetworkReadiness.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/NetworkReadiness.kt new file mode 100644 index 0000000..9e48dbc --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/NetworkReadiness.kt @@ -0,0 +1,6 @@ +package com.huaga.life_echo.network.runtime + +enum class NetworkReadiness { + NotInitialized, + ClientReady, +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt new file mode 100644 index 0000000..6f6c8db --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RealtimeTransportProvider.kt @@ -0,0 +1,31 @@ +package com.huaga.life_echo.network.runtime + +import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class RealtimeTransportProvider( + private val factory: () -> HttpClient, +) { + private var client: HttpClient? = null + private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized) + val readiness: StateFlow = _readiness.asStateFlow() + + fun warmUp() { + getClient() + } + + fun getClient(): HttpClient { + return client ?: factory().also { + client = it + _readiness.value = NetworkReadiness.ClientReady + } + } + + fun close() { + client?.close() + client = null + _readiness.value = NetworkReadiness.NotInitialized + } +} diff --git a/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt new file mode 100644 index 0000000..5587fbc --- /dev/null +++ b/app-android/app/src/main/java/com/huaga/life_echo/network/runtime/RestClientProvider.kt @@ -0,0 +1,34 @@ +package com.huaga.life_echo.network.runtime + +import io.ktor.client.HttpClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +enum class RestClientProfile { AUTH, API } + +class RestClientProvider( + private val factory: (RestClientProfile) -> HttpClient, +) { + private val clients = mutableMapOf() + private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized) + val readiness: StateFlow = _readiness.asStateFlow() + + fun getClient(profile: RestClientProfile): HttpClient { + return clients.getOrPut(profile) { + factory(profile).also { + _readiness.value = NetworkReadiness.ClientReady + } + } + } + + fun warmUp() { + RestClientProfile.entries.forEach { getClient(it) } + } + + fun close() { + clients.values.forEach { it.close() } + clients.clear() + _readiness.value = NetworkReadiness.NotInitialized + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt new file mode 100644 index 0000000..c0c0433 --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RealtimeTransportProviderTest.kt @@ -0,0 +1,43 @@ +package com.huaga.life_echo.network.runtime + +import io.ktor.client.HttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.Mockito + +class RealtimeTransportProviderTest { + + private val fakeFactory: () -> HttpClient = { + Mockito.mock(HttpClient::class.java) + } + + @Test + fun realtime_provider_only_reports_client_ready_after_warmup_or_getClient() { + val provider = RealtimeTransportProvider(factory = fakeFactory) + + assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value) + provider.warmUp() + assertEquals(NetworkReadiness.ClientReady, provider.readiness.value) + } + + @Test + fun get_client_returns_same_instance() { + val provider = RealtimeTransportProvider(factory = fakeFactory) + + val client1 = provider.getClient() + val client2 = provider.getClient() + + assertSame(client1, client2) + } + + @Test + fun close_resets_readiness_to_not_initialized() { + val provider = RealtimeTransportProvider(factory = fakeFactory) + provider.warmUp() + + provider.close() + + assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value) + } +} diff --git a/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt new file mode 100644 index 0000000..ac3580d --- /dev/null +++ b/app-android/app/src/test/java/com/huaga/life_echo/network/runtime/RestClientProviderTest.kt @@ -0,0 +1,66 @@ +package com.huaga.life_echo.network.runtime + +import io.ktor.client.HttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import org.mockito.Mockito + +class RestClientProviderTest { + + private val fakeFactory: (RestClientProfile) -> HttpClient = { + Mockito.mock(HttpClient::class.java) + } + + @Test + fun auth_and_api_clients_are_stable_per_profile_until_close() { + val provider = RestClientProvider(factory = fakeFactory) + val auth1 = provider.getClient(RestClientProfile.AUTH) + val auth2 = provider.getClient(RestClientProfile.AUTH) + val api1 = provider.getClient(RestClientProfile.API) + + assertSame(auth1, auth2) + assertNotSame(auth1, api1) + } + + @Test + fun readiness_is_not_initialized_before_any_client_is_requested() { + val provider = RestClientProvider(factory = fakeFactory) + + assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value) + } + + @Test + fun readiness_is_client_ready_after_get_client() { + val provider = RestClientProvider(factory = fakeFactory) + + provider.getClient(RestClientProfile.AUTH) + + assertEquals(NetworkReadiness.ClientReady, provider.readiness.value) + } + + @Test + fun warm_up_creates_all_profiles_and_reports_ready() { + val created = mutableListOf() + val provider = RestClientProvider { profile -> + created += profile + Mockito.mock(HttpClient::class.java) + } + + provider.warmUp() + + assertEquals(RestClientProfile.entries.toList(), created) + assertEquals(NetworkReadiness.ClientReady, provider.readiness.value) + } + + @Test + fun close_resets_readiness_to_not_initialized() { + val provider = RestClientProvider(factory = fakeFactory) + provider.warmUp() + + provider.close() + + assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value) + } +}