diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7c7866e69..ea5dabc93 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,7 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk.mockk) testImplementation(libs.robolectric.robolectric) + testImplementation(libs.square.okhttp.mockwebserver) testImplementation(libs.square.turbine) detektPlugins(libs.detekt.detekt.formatting) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCall.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCall.kt new file mode 100644 index 000000000..294505eb2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCall.kt @@ -0,0 +1,62 @@ +package com.x8bit.bitwarden.data.datasource.network + +import okhttp3.Request +import okio.Timeout +import retrofit2.Call +import retrofit2.Callback +import retrofit2.HttpException +import retrofit2.Response +import retrofit2.Response.success +import java.lang.reflect.Type + +/** + * A [Call] for wrapping a network request into a [Result]. + */ +class ResultCall( + private val backingCall: Call, + private val successType: Type, +) : Call> { + override fun cancel(): Unit = backingCall.cancel() + + override fun clone(): Call> = ResultCall(backingCall, successType) + + @Suppress("UNCHECKED_CAST") + private fun createResult(body: T?): Result { + return when { + body != null -> Result.success(body) + successType == Unit::class.java -> Result.success(Unit as T) + else -> Result.failure(IllegalStateException("Unexpected null body!")) + } + } + + override fun enqueue(callback: Callback>): Unit = backingCall.enqueue( + object : Callback { + override fun onResponse(call: Call, response: Response) { + val body = response.body() + val result: Result = if (!response.isSuccessful) { + Result.failure(HttpException(response)) + } else { + createResult(body) + } + callback.onResponse(this@ResultCall, success(result)) + } + + override fun onFailure(call: Call, t: Throwable) { + val result: Result = Result.failure(t) + callback.onResponse(this@ResultCall, success(result)) + } + }, + ) + + override fun execute(): Response> = throw UnsupportedOperationException( + "This call can't be executed synchronously", + ) + + override fun isCanceled(): Boolean = backingCall.isCanceled + + override fun isExecuted(): Boolean = backingCall.isExecuted + + override fun request(): Request = backingCall.request() + + override fun timeout(): Timeout = backingCall.timeout() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapter.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapter.kt new file mode 100644 index 000000000..65ec931c4 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapter.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.datasource.network + +import retrofit2.Call +import retrofit2.CallAdapter +import java.lang.reflect.Type + +/** + * A [CallAdapter] for wrapping network requests into [kotlin.Result]. + */ +class ResultCallAdapter( + private val successType: Type, +) : CallAdapter>> { + + override fun responseType(): Type = successType + override fun adapt(call: Call): Call> = ResultCall(call, successType) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapterFactory.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapterFactory.kt new file mode 100644 index 000000000..fcc767018 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/ResultCallAdapterFactory.kt @@ -0,0 +1,32 @@ +package com.x8bit.bitwarden.data.datasource.network + +import retrofit2.Call +import retrofit2.CallAdapter +import retrofit2.Retrofit +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type + +/** + * A [CallAdapter.Factory] for wrapping network requests into [kotlin.Result]. + */ +class ResultCallAdapterFactory : CallAdapter.Factory() { + override fun get( + returnType: Type, + annotations: Array, + retrofit: Retrofit, + ): CallAdapter<*, *>? { + check(returnType is ParameterizedType) { "$returnType must be parameterized" } + val containerType = getParameterUpperBound(0, returnType) + + if (getRawType(containerType) != Result::class.java) return null + check(containerType is ParameterizedType) { "$containerType must be parameterized" } + + val requestType = getParameterUpperBound(0, containerType) + + return if (getRawType(returnType) == Call::class.java) { + ResultCallAdapter(successType = requestType) + } else { + null + } + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/api/ConfigApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/api/ConfigApi.kt new file mode 100644 index 000000000..b3b3a71e1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/api/ConfigApi.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.datasource.network.api + +import com.x8bit.bitwarden.data.datasource.network.models.ConfigResponseJson +import retrofit2.http.GET + +/** + * This interface defines the API service for fetching configuration data. + */ +interface ConfigApi { + /** + * Retrieves the configuration data from the server. + * + * @return A [ConfigResponseJson] containing the configuration response model. + */ + @GET("config") + suspend fun getConfig(): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/di/NetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/di/NetworkModule.kt new file mode 100644 index 000000000..4f076133f --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/di/NetworkModule.kt @@ -0,0 +1,55 @@ +package com.x8bit.bitwarden.data.datasource.network.di + +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import com.x8bit.bitwarden.data.datasource.network.ResultCallAdapterFactory +import com.x8bit.bitwarden.data.datasource.network.api.ConfigApi +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import javax.inject.Singleton + +/** + * This class provides network-related functionality for the application. + * It initializes and configures the networking components. + */ +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + + @Provides + @Singleton + fun provideConfigApiService(retrofit: Retrofit): ConfigApi { + return retrofit.create(ConfigApi::class.java) + } + + @Provides + @Singleton + fun provideOkHttpClient(): OkHttpClient { + return OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BODY) + }, + ) + .build() + } + + @Provides + @Singleton + fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit { + val contentType = "application/json".toMediaType() + + return Retrofit.Builder() + .baseUrl("https://api.bitwarden.com") + .client(okHttpClient) + .addConverterFactory(Json.asConverterFactory(contentType)) + .addCallAdapterFactory(ResultCallAdapterFactory()) + .build() + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/models/ConfigResponseJson.kt b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/models/ConfigResponseJson.kt new file mode 100644 index 000000000..c573b2a55 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/datasource/network/models/ConfigResponseJson.kt @@ -0,0 +1,70 @@ +package com.x8bit.bitwarden.data.datasource.network.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the response model for configuration data fetched from the server. + * + * @property type The object type, typically "config". + * @property version The version of the configuration data. + * @property gitHash The Git hash associated with the configuration data. + * @property server The server information (nullable). + * @property environment The environment information containing URLs (vault, api, identity, etc.). + * @property featureStates A map containing various feature states. + */ +@Serializable +data class ConfigResponseJson( + @SerialName("object") + val type: String?, + @SerialName("version") + val version: String?, + @SerialName("gitHash") + val gitHash: String?, + @SerialName("server") + val server: ServerJson?, + @SerialName("environment") + val environment: EnvironmentJson?, + @SerialName("featureStates") + val featureStates: Map?, +) { + /** + * Represents a server in the configuration response. + * + * @param name The name of the server. + * @param url The URL of the server. + */ + @Serializable + data class ServerJson( + @SerialName("name") + val name: String?, + @SerialName("url") + val url: String?, + ) + + /** + * Represents the environment details in the configuration response. + * + * @param cloudRegion The cloud region associated with the environment. + * @param vaultUrl The URL of the vault service in the environment. + * @param apiUrl The URL of the API service in the environment. + * @param identityUrl The URL of the identity service in the environment. + * @param notificationsUrl The URL of the notifications service in the environment. + * @param ssoUrl The URL of the single sign-on (SSO) service in the environment. + */ + @Serializable + data class EnvironmentJson( + @SerialName("cloudRegion") + val cloudRegion: String?, + @SerialName("vault") + val vaultUrl: String?, + @SerialName("api") + val apiUrl: String?, + @SerialName("identity") + val identityUrl: String?, + @SerialName("notifications") + val notificationsUrl: String?, + @SerialName("sso") + val ssoUrl: String?, + ) +} diff --git a/app/src/test/java/com/x8bit/bitwarden/example/ResultCallAdapterTests.kt b/app/src/test/java/com/x8bit/bitwarden/example/ResultCallAdapterTests.kt new file mode 100644 index 000000000..93aad4cff --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/example/ResultCallAdapterTests.kt @@ -0,0 +1,51 @@ +package com.x8bit.bitwarden.example + +import com.x8bit.bitwarden.data.datasource.network.ResultCallAdapterFactory +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import retrofit2.Retrofit +import retrofit2.create +import retrofit2.http.GET + +class ResultCallAdapterTests { + + private val server: MockWebServer = MockWebServer().apply { start() } + private val testService: FakeService = + Retrofit.Builder() + .baseUrl(server.url("/").toString()) + // add the adapter being tested + .addCallAdapterFactory(ResultCallAdapterFactory()) + .build() + .create() + + @After + fun after() { + server.shutdown() + } + + @Test + fun `when server returns error response code result should be failure`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(500)) + val result = testService.requestWithUnitData() + assertTrue(result.isFailure) + } + + @Test + fun `when server returns successful response result should be success`() = runBlocking { + server.enqueue(MockResponse()) + val result = testService.requestWithUnitData() + assertTrue(result.isSuccess) + } +} + +/** + * Fake retrofit service used for testing call adapters. + */ +private interface FakeService { + @GET("/fake") + suspend fun requestWithUnitData(): Result +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4c9b8cbff..4414f4544 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -77,6 +77,7 @@ nulab-zxcvbn4j = { module = "com.nulab-inc:zxcvbn", version.ref = "zxcvbn4j" } robolectric-robolectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" } square-okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } square-okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } +square-okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } square-retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } square-turbine = { module = "app.cash.turbine:turbine", version.ref = "turbine" } zxing-zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }