diff --git a/template-compose/.gitignore b/template-compose/.gitignore index 335fdcaac..6c12f8efd 100644 --- a/template-compose/.gitignore +++ b/template-compose/.gitignore @@ -22,6 +22,7 @@ out/ # Local configuration file (sdk path, etc) local.properties +api-config.properties # Proguard folder generated by Eclipse proguard/ diff --git a/template-compose/app/build.gradle.kts b/template-compose/app/build.gradle.kts index ed899e49b..bf45a5ac5 100644 --- a/template-compose/app/build.gradle.kts +++ b/template-compose/app/build.gradle.kts @@ -59,14 +59,12 @@ android { isShrinkResources = true proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") signingConfig = signingConfigs[BuildTypes.RELEASE] - buildConfigField("String", "BASE_API_URL", "\"https://jsonplaceholder.typicode.com/\"") } debug { // For quickly testing build with proguard, enable this isMinifyEnabled = false signingConfig = signingConfigs[BuildTypes.DEBUG] - buildConfigField("String", "BASE_API_URL", "\"https://jsonplaceholder.typicode.com/\"") } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/AuthQualifiers.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/AuthQualifiers.kt new file mode 100644 index 000000000..924da3073 --- /dev/null +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/AuthQualifiers.kt @@ -0,0 +1,9 @@ +package co.nimblehq.template.compose.di + +import javax.inject.Qualifier + +@Qualifier +annotation class Unauthorized + +@Qualifier +annotation class Authorized diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/AppModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/AppModule.kt index 40b7cd213..b7a8f566c 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/AppModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/AppModule.kt @@ -1,11 +1,15 @@ package co.nimblehq.template.compose.di.modules -import co.nimblehq.template.compose.util.DispatchersProvider -import co.nimblehq.template.compose.util.DispatchersProviderImpl +import co.nimblehq.template.compose.data.remote.services.ApiService +import co.nimblehq.template.compose.data.repositories.TokenRepositoryImpl +import co.nimblehq.template.compose.data.util.DispatchersProvider +import co.nimblehq.template.compose.data.util.DispatchersProviderImpl +import co.nimblehq.template.compose.domain.repositories.TokenRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import java.util.Properties @Module @InstallIn(SingletonComponent::class) @@ -14,4 +18,13 @@ class AppModule { fun provideDispatchersProvider(): DispatchersProvider { return DispatchersProviderImpl() } + + @Provides + fun provideTokenRepository( + apiService: ApiService, + apiConfigProperties: Properties, + ): TokenRepository = TokenRepositoryImpl( + apiService = apiService, + apiConfigProperties = apiConfigProperties, + ) } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/OkHttpClientModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/OkHttpClientModule.kt index 563410f17..087807535 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/OkHttpClientModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/OkHttpClientModule.kt @@ -2,6 +2,15 @@ package co.nimblehq.template.compose.di.modules import android.content.Context import co.nimblehq.template.compose.BuildConfig +import co.nimblehq.template.compose.data.remote.interceptor.AuthInterceptor +import co.nimblehq.template.compose.data.local.preferences.NetworkEncryptedSharedPreferences +import co.nimblehq.template.compose.data.remote.authenticator.RequestAuthenticator +import co.nimblehq.template.compose.data.util.DispatchersProvider +import co.nimblehq.template.compose.di.Authorized +import co.nimblehq.template.compose.di.Unauthorized +import co.nimblehq.template.compose.domain.usecases.GetAuthStatusUseCase +import co.nimblehq.template.compose.domain.usecases.RefreshTokenUseCase +import co.nimblehq.template.compose.domain.usecases.UpdateLoginTokensUseCase import com.chuckerteam.chucker.api.* import dagger.Module import dagger.Provides @@ -18,6 +27,7 @@ private const val READ_TIME_OUT = 30L @InstallIn(SingletonComponent::class) class OkHttpClientModule { + @Unauthorized @Provides fun provideOkHttpClient( chuckerInterceptor: ChuckerInterceptor @@ -31,6 +41,26 @@ class OkHttpClientModule { } }.build() + @Authorized + @Provides + fun provideAuthorizedOkHttpClient( + authInterceptor: AuthInterceptor, + chuckerInterceptor: ChuckerInterceptor, + authenticator: RequestAuthenticator, + ) = OkHttpClient.Builder().apply { + addInterceptor(authInterceptor) + authenticator(authenticator) + if (BuildConfig.DEBUG) { + addInterceptor(HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + }) + addInterceptor(chuckerInterceptor) + readTimeout(READ_TIME_OUT, TimeUnit.SECONDS) + } + }.build().apply { + authenticator.okHttpClient = this + } + @Provides fun provideChuckerInterceptor( @ApplicationContext context: Context @@ -46,4 +76,31 @@ class OkHttpClientModule { .alwaysReadResponseBody(true) .build() } + + @Provides + fun provideAuthInterceptor( + encryptedSharedPreference: NetworkEncryptedSharedPreferences + ): AuthInterceptor { + return AuthInterceptor(encryptedSharedPreference) + } + + @Provides + fun provideNetworkEncryptedSharedPreferences( + @ApplicationContext context: Context, + ): NetworkEncryptedSharedPreferences { + return NetworkEncryptedSharedPreferences(context) + } + + @Provides + fun provideRequestAuthenticator( + dispatchersProvider: DispatchersProvider, + getAuthStatusUseCase: GetAuthStatusUseCase, + refreshTokenUseCase: RefreshTokenUseCase, + updateLoginTokensUseCase: UpdateLoginTokensUseCase, + ): RequestAuthenticator = RequestAuthenticator( + dispatchersProvider = dispatchersProvider, + getAuthStatusUseCase = getAuthStatusUseCase, + refreshTokenUseCase = refreshTokenUseCase, + updateLoginTokensUseCase = updateLoginTokensUseCase, + ) } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/PreferencesModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/PreferencesModule.kt index aa1857a6c..0ae987e5b 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/PreferencesModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/PreferencesModule.kt @@ -6,7 +6,9 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.preferencesDataStoreFile import co.nimblehq.template.compose.data.repositories.AppPreferencesRepositoryImpl +import co.nimblehq.template.compose.data.repositories.AuthPreferenceRepositoryImpl import co.nimblehq.template.compose.domain.repositories.AppPreferencesRepository +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository import dagger.* import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext @@ -24,6 +26,11 @@ abstract class PreferencesModule { appPreferencesRepositoryImpl: AppPreferencesRepositoryImpl ): AppPreferencesRepository + @Binds + abstract fun bindAuthPreferencesRepository( + authPreferenceRepositoryImpl: AuthPreferenceRepositoryImpl + ): AuthPreferenceRepository + companion object { @Singleton @Provides diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/RetrofitModule.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/RetrofitModule.kt index 7fb90ab68..670acb7c6 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/RetrofitModule.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/di/modules/RetrofitModule.kt @@ -1,8 +1,10 @@ package co.nimblehq.template.compose.di.modules -import co.nimblehq.template.compose.BuildConfig import co.nimblehq.template.compose.data.remote.providers.* import co.nimblehq.template.compose.data.remote.services.ApiService +import co.nimblehq.template.compose.data.remote.services.AuthorizedApiService +import co.nimblehq.template.compose.di.Authorized +import co.nimblehq.template.compose.di.Unauthorized import com.squareup.moshi.Moshi import dagger.Module import dagger.Provides @@ -11,28 +13,61 @@ import dagger.hilt.components.SingletonComponent import okhttp3.OkHttpClient import retrofit2.Converter import retrofit2.Retrofit +import java.util.Properties + +private const val API_CONFIG_PROPERTIES = "api-config.properties" @Module @InstallIn(SingletonComponent::class) class RetrofitModule { @Provides - fun provideBaseApiUrl() = BuildConfig.BASE_API_URL + fun provideBaseApiUrl(apiConfigProperties: Properties): String = + apiConfigProperties.getProperty("BASE_API_URL") @Provides fun provideMoshiConverterFactory(moshi: Moshi): Converter.Factory = ConverterFactoryProvider.getMoshiConverterFactory(moshi) + @Unauthorized @Provides fun provideRetrofit( baseUrl: String, - okHttpClient: OkHttpClient, + @Unauthorized okHttpClient: OkHttpClient, + converterFactory: Converter.Factory, + ): Retrofit = RetrofitProvider + .getRetrofitBuilder(baseUrl, okHttpClient, converterFactory) + .build() + + @Authorized + @Provides + fun provideAuthorizedRetrofit( + baseUrl: String, + @Authorized okHttpClient: OkHttpClient, converterFactory: Converter.Factory, ): Retrofit = RetrofitProvider .getRetrofitBuilder(baseUrl, okHttpClient, converterFactory) .build() @Provides - fun provideApiService(retrofit: Retrofit): ApiService = + fun provideApiService(@Unauthorized retrofit: Retrofit): ApiService = ApiServiceProvider.getApiService(retrofit) + + @Provides + fun provideAuthorizedApiService(@Authorized retrofit: Retrofit): AuthorizedApiService = + ApiServiceProvider.getAuthorizedService(retrofit) + + @Provides + fun loadApiConfigProperties(): Properties { + val properties = Properties() + val inputStream = this.javaClass.classLoader?.getResourceAsStream(API_CONFIG_PROPERTIES) + ?: throw IllegalArgumentException( + "$API_CONFIG_PROPERTIES file not found. " + + "Please add $API_CONFIG_PROPERTIES in the :app module /resources folder" + ) + + properties.load(inputStream) + + return properties + } } diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModel.kt b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModel.kt index 0f2c7a82b..33c82a088 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModel.kt +++ b/template-compose/app/src/main/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModel.kt @@ -5,7 +5,7 @@ import co.nimblehq.template.compose.domain.usecases.UseCase import co.nimblehq.template.compose.ui.base.BaseViewModel import co.nimblehq.template.compose.ui.models.UiModel import co.nimblehq.template.compose.ui.models.toUiModel -import co.nimblehq.template.compose.util.DispatchersProvider +import co.nimblehq.template.compose.data.util.DispatchersProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.* import javax.inject.Inject diff --git a/template-compose/app/src/main/resources/api-config.properties.sample b/template-compose/app/src/main/resources/api-config.properties.sample new file mode 100644 index 000000000..39f12f82f --- /dev/null +++ b/template-compose/app/src/main/resources/api-config.properties.sample @@ -0,0 +1,3 @@ +BASE_API_URL=BASE_API_URL +CLIENT_ID=CLIENT_ID +CLIENT_SECRET=CLIENT_SECRET diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/test/CoroutineTestRule.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/test/CoroutineTestRule.kt index 5e9ba791d..d0cee7c61 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/test/CoroutineTestRule.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/test/CoroutineTestRule.kt @@ -1,6 +1,6 @@ package co.nimblehq.template.compose.test -import co.nimblehq.template.compose.util.DispatchersProvider +import co.nimblehq.template.compose.data.util.DispatchersProvider import kotlinx.coroutines.* import kotlinx.coroutines.test.* import org.junit.rules.TestWatcher diff --git a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModelTest.kt b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModelTest.kt index d520c6bea..f3112b5f0 100644 --- a/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModelTest.kt +++ b/template-compose/app/src/test/java/co/nimblehq/template/compose/ui/screens/main/home/HomeViewModelTest.kt @@ -5,7 +5,7 @@ import co.nimblehq.template.compose.domain.usecases.UseCase import co.nimblehq.template.compose.test.CoroutineTestRule import co.nimblehq.template.compose.test.MockUtil import co.nimblehq.template.compose.ui.models.toUiModel -import co.nimblehq.template.compose.util.DispatchersProvider +import co.nimblehq.template.compose.data.util.DispatchersProvider import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.mockk diff --git a/template-compose/buildSrc/src/main/java/Versions.kt b/template-compose/buildSrc/src/main/java/Versions.kt index 04e5458ab..8988f837a 100644 --- a/template-compose/buildSrc/src/main/java/Versions.kt +++ b/template-compose/buildSrc/src/main/java/Versions.kt @@ -42,7 +42,7 @@ object Versions { const val RETROFIT = "2.9.0" const val ROBOLECTRIC = "4.10.2" - const val SECURITY_CRYPTO = "1.0.0" + const val SECURITY_CRYPTO = "1.1.0-alpha06" const val TIMBER = "4.7.1" const val TURBINE = "0.13.0" diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseEncryptedSharedPreferences.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseEncryptedSharedPreferences.kt new file mode 100644 index 000000000..8fd148750 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseEncryptedSharedPreferences.kt @@ -0,0 +1,35 @@ +package co.nimblehq.template.compose.data.local.preferences + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import java.security.KeyStore + +abstract class BaseEncryptedSharedPreferences : BaseSharedPreferences() { + + fun deleteExistingPreferences(fileName: String, context: Context) { + context.deleteSharedPreferences(fileName) + } + + fun deleteMasterKeyEntry() { + KeyStore.getInstance("AndroidKeyStore").apply { + load(null) + deleteEntry(MasterKey.DEFAULT_MASTER_KEY_ALIAS) + } + } + + fun createEncryptedSharedPreferences(fileName: String, context: Context): SharedPreferences { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + return EncryptedSharedPreferences.create( + context, + fileName, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseSharedPreferences.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseSharedPreferences.kt index 4b30ea8dc..5aef2dfd1 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseSharedPreferences.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/BaseSharedPreferences.kt @@ -4,9 +4,9 @@ import android.content.SharedPreferences abstract class BaseSharedPreferences { - protected lateinit var sharedPreferences: SharedPreferences + lateinit var sharedPreferences: SharedPreferences - protected inline fun get(key: String): T? = + inline fun get(key: String): T? = if (sharedPreferences.contains(key)) { when (T::class) { Boolean::class -> sharedPreferences.getBoolean(key, false) as T? @@ -20,8 +20,8 @@ abstract class BaseSharedPreferences { null } - protected fun set(key: String, value: T) { - sharedPreferences.execute { + fun set(key: String, value: T, executeWithCommit: Boolean = false) { + sharedPreferences.execute(executeWithCommit) { when (value) { is Boolean -> it.putBoolean(key, value) is String -> it.putString(key, value) @@ -32,11 +32,11 @@ abstract class BaseSharedPreferences { } } - protected fun remove(key: String) { - sharedPreferences.execute { it.remove(key) } + fun remove(key: String, executeWithCommit: Boolean = false) { + sharedPreferences.execute(executeWithCommit) { it.remove(key) } } - protected fun clearAll() { - sharedPreferences.execute { it.clear() } + fun clearAll(executeWithCommit: Boolean = false) { + sharedPreferences.execute(executeWithCommit) { it.clear() } } } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/EncryptedSharedPreferences.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/EncryptedSharedPreferences.kt index baa85c780..dfafc40f2 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/EncryptedSharedPreferences.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/EncryptedSharedPreferences.kt @@ -1,23 +1,17 @@ package co.nimblehq.template.compose.data.local.preferences import android.content.Context -import androidx.security.crypto.EncryptedSharedPreferences -import androidx.security.crypto.MasterKeys import javax.inject.Inject private const val APP_SECRET_SHARED_PREFS = "app_secret_shared_prefs" class EncryptedSharedPreferences @Inject constructor(applicationContext: Context) : - BaseSharedPreferences() { + BaseEncryptedSharedPreferences() { init { - val masterKey = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) - sharedPreferences = EncryptedSharedPreferences.create( - APP_SECRET_SHARED_PREFS, - masterKey, - applicationContext, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + sharedPreferences = createEncryptedSharedPreferences( + fileName = APP_SECRET_SHARED_PREFS, + context = applicationContext ) } } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/NetworkEncryptedSharedPreferences.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/NetworkEncryptedSharedPreferences.kt new file mode 100644 index 000000000..7545cfe5c --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/NetworkEncryptedSharedPreferences.kt @@ -0,0 +1,119 @@ +package co.nimblehq.template.compose.data.local.preferences + +import android.content.Context +import co.nimblehq.template.compose.domain.models.AuthStatus +import java.security.GeneralSecurityException + +private const val NETWORK_SECRET_SHARED_PREFS = "network_secret_shared_prefs" + +private const val ACCESS_TOKEN = "ACCESS_TOKEN" +private const val TOKEN_TYPE = "TOKEN_TYPE" +private const val EXPIRES_IN = "EXPIRES_IN" +private const val REFRESH_TOKEN = "REFRESH_TOKEN" + +class NetworkEncryptedSharedPreferences constructor( + applicationContext: Context, +) : BaseEncryptedSharedPreferences() { + + init { + // Add try/catch to fix the crash on initializing: https://github.com/google/tink/issues/535 + sharedPreferences = try { + createEncryptedSharedPreferences(NETWORK_SECRET_SHARED_PREFS, applicationContext) + } catch (e: GeneralSecurityException) { + // Workaround solution: https://github.com/google/tink/issues/535#issuecomment-912170221 + deleteMasterKeyEntry() + deleteExistingPreferences(NETWORK_SECRET_SHARED_PREFS, applicationContext) + createEncryptedSharedPreferences(NETWORK_SECRET_SHARED_PREFS, applicationContext) + } + } + + private var accessToken: String? + get() = get(ACCESS_TOKEN) + set(value) = if (value == null) { + remove( + key = ACCESS_TOKEN, + executeWithCommit = true + ) + } else { + set( + key = ACCESS_TOKEN, + value = value, + executeWithCommit = true + ) + } + + private var tokenType: String? + get() = get(TOKEN_TYPE) + set(value) = if (value == null) { + remove( + key = TOKEN_TYPE, + executeWithCommit = true + ) + } else { + set( + key = TOKEN_TYPE, + value = value, + executeWithCommit = true + ) + } + + private var expiresIn: Int? + get() = get(EXPIRES_IN) + set(value) = if (value == null) { + remove( + key = EXPIRES_IN, + executeWithCommit = true + ) + } else { + set( + key = EXPIRES_IN, + value = value, + executeWithCommit = true + ) + } + + private var refreshToken: String? + get() = get(REFRESH_TOKEN) + set(value) = if (value == null) { + remove( + key = REFRESH_TOKEN, + executeWithCommit = true + ) + } else { + set( + key = REFRESH_TOKEN, + value = value, + executeWithCommit = true + ) + } + + var authStatus: AuthStatus + get() { + return if ( + accessToken.isNullOrEmpty() + && tokenType.isNullOrEmpty() + && expiresIn == null + && refreshToken.isNullOrEmpty() + ) { + AuthStatus.Anonymous + } else { + AuthStatus.Authenticated( + accessToken = accessToken.orEmpty(), + tokenType = tokenType, + expiresIn = expiresIn, + refreshToken = refreshToken.orEmpty(), + ) + } + } + set(value) { + if (value is AuthStatus.Authenticated) { + accessToken = value.accessToken + tokenType = value.tokenType + expiresIn = value.expiresIn + refreshToken = value.refreshToken + } else { + // AuthStatus.Anonymous means logging out + clearAll(executeWithCommit = true) + } + } +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/SharedPreferencesExt.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/SharedPreferencesExt.kt index 1da4553f1..ba9918806 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/SharedPreferencesExt.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/local/preferences/SharedPreferencesExt.kt @@ -2,9 +2,12 @@ package co.nimblehq.template.compose.data.local.preferences import android.content.SharedPreferences -fun SharedPreferences.execute(operation: (SharedPreferences.Editor) -> Unit) { +fun SharedPreferences.execute( + executeWithCommit: Boolean = false, + operation: (SharedPreferences.Editor) -> Unit +) { with(edit()) { operation(this) - apply() + if (executeWithCommit) commit() else apply() } } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestAuthenticator.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestAuthenticator.kt new file mode 100644 index 000000000..0957adfc4 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestAuthenticator.kt @@ -0,0 +1,130 @@ +package co.nimblehq.template.compose.data.remote.authenticator + +import co.nimblehq.template.compose.data.remote.interceptor.AUTH_HEADER +import co.nimblehq.template.compose.data.util.DispatchersProvider +import co.nimblehq.template.compose.domain.exceptions.NoConnectivityException +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.models.toAuthenticatedStatus +import co.nimblehq.template.compose.domain.usecases.GetAuthStatusUseCase +import co.nimblehq.template.compose.domain.usecases.RefreshTokenUseCase +import co.nimblehq.template.compose.domain.usecases.UpdateLoginTokensUseCase +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.runBlocking +import okhttp3.Authenticator +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import kotlin.math.pow + +private const val MAX_ATTEMPTS = 3 +private const val EXPONENTIAL_BACKOFF_BASE = 2.0 +private const val EXPONENTIAL_BACKOFF_SCALE = 0.5 +private const val SECOND_IN_MILLISECONDS = 1000 + +class RequestAuthenticator( + private val dispatchersProvider: DispatchersProvider, + private val getAuthStatusUseCase: GetAuthStatusUseCase, + private val refreshTokenUseCase: RefreshTokenUseCase, + private val updateLoginTokensUseCase: UpdateLoginTokensUseCase, +) : Authenticator { + + var okHttpClient: OkHttpClient? = null + private var retryCount = 0 + private var retryDelay = 0L + + override fun authenticate(route: Route?, response: Response): Request? { + // Due to unable to check the last retry succeeded + // So reset the retry count on the request first triggered by an automatic retry + if (response.priorResponse == null && retryCount != 0) { + retryCount = 0 + } + + return if (retryCount >= MAX_ATTEMPTS) { + // Reset retry count once reached max attempts + retryCount = 0 + null + } else { + retryCount++ + + retryDelay = calculateExponentialBackoff(retryCount).toLong() + runBlocking { delay(retryDelay * SECOND_IN_MILLISECONDS) } + + retryWithNewToken(response) + } + } + + @Suppress("TooGenericExceptionCaught", "NestedBlockDepth") + @Synchronized + private fun retryWithNewToken(response: Response): Request? = try { + val (tokenType, accessToken) = run { + val authStatus = getAuthStatus() as? AuthStatus.Authenticated + val tokenType = authStatus?.tokenType.orEmpty() + val accessToken = authStatus?.accessToken.orEmpty() + val refreshToken = authStatus?.refreshToken.orEmpty() + + val headerAccessToken = response.request.header(AUTH_HEADER)?.removePrefix(tokenType)?.trim() + + if (accessToken == headerAccessToken) { + val newAuthStatus = refreshAccessToken(refreshToken) + val newTokenType = newAuthStatus?.tokenType.orEmpty() + val newAccessToken = newAuthStatus?.accessToken.orEmpty() + + if (newAccessToken.isEmpty() || newAccessToken == accessToken) { + // Avoid infinite loop if the new Token == old (failed) token + return null + } + + // Update the token (for future requests) + newAuthStatus?.let(::updateToken) + + // Retry with the NEW token + newTokenType to newAccessToken + } else { + // Retry with the EXISTING token + tokenType to accessToken + } + } + + response.newRequestWithToken( + tokenType = tokenType, + accessToken = accessToken + ) + } catch (e: Exception) { + if (e !is NoConnectivityException) { + // send force logout request to view layer + RequestInterceptingDelegate.requestForceLogout() + + // cancel all pending requests + okHttpClient?.dispatcher?.cancelAll() + response.request + } else { + // do nothing + null + } + } + + private fun calculateExponentialBackoff(retryCount: Int): Double { + return EXPONENTIAL_BACKOFF_BASE.pow(retryCount.toDouble()) * EXPONENTIAL_BACKOFF_SCALE + } + + private fun getAuthStatus(): AuthStatus? = runBlocking(dispatchersProvider.io) { + getAuthStatusUseCase().firstOrNull() + } + + private fun updateToken(authStatus: AuthStatus) = runBlocking(dispatchersProvider.io) { + updateLoginTokensUseCase(authStatus).collect() + } + + private fun refreshAccessToken(refreshToken: String): AuthStatus.Authenticated? = + runBlocking(dispatchersProvider.io) { + refreshTokenUseCase(refreshToken).firstOrNull()?.toAuthenticatedStatus() + } + + private fun Response.newRequestWithToken(tokenType: String, accessToken: String): Request = + request.newBuilder() + .header(AUTH_HEADER, "$tokenType $accessToken") + .build() +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestInterceptingDelegate.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestInterceptingDelegate.kt new file mode 100644 index 000000000..731fccbfc --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticator/RequestInterceptingDelegate.kt @@ -0,0 +1,18 @@ +package co.nimblehq.template.compose.data.remote.authenticator + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.runBlocking + +object RequestInterceptingDelegate { + + private val _isForcedLogout: MutableSharedFlow = MutableSharedFlow() + val isForcedLogout = _isForcedLogout.asSharedFlow() + + @Synchronized + internal fun requestForceLogout() { + runBlocking { + _isForcedLogout.emit(Unit) + } + } +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticators/.keep b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/authenticators/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/interceptor/AuthInterceptor.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/interceptor/AuthInterceptor.kt new file mode 100644 index 000000000..a17af67b2 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/interceptor/AuthInterceptor.kt @@ -0,0 +1,29 @@ +package co.nimblehq.template.compose.data.remote.interceptor + +import co.nimblehq.template.compose.data.local.preferences.NetworkEncryptedSharedPreferences +import co.nimblehq.template.compose.domain.models.AuthStatus +import okhttp3.Interceptor +import okhttp3.Response +import javax.inject.Inject + +const val AUTH_HEADER = "Authorization" + +class AuthInterceptor @Inject constructor( + private val preferences: NetworkEncryptedSharedPreferences, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + return if (preferences.authStatus is AuthStatus.Authenticated) { + val authStatus = preferences.authStatus as AuthStatus.Authenticated + chain.request() + .newBuilder() + .header( + name = AUTH_HEADER, + value = "${authStatus.tokenType} ${authStatus.accessToken}" + ) + .build() + .let(chain::proceed) + } else { + chain.proceed(chain.request()) + } + } +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/interceptors/.keep b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/interceptors/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/requests/.keep b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/requests/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/requests/RefreshTokenRequest.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/requests/RefreshTokenRequest.kt new file mode 100644 index 000000000..708114702 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/requests/RefreshTokenRequest.kt @@ -0,0 +1,15 @@ +package co.nimblehq.template.compose.data.remote.models.requests + +import com.squareup.moshi.Json + +data class RefreshTokenRequest( + @Json(name = "grantType") + val grantType: String, + @Json(name = "refreshToken") + val refreshToken: String, +) + +internal fun String.toRefreshTokenRequest() = RefreshTokenRequest( + grantType = "refresh_token", + refreshToken = this, +) diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/responses/OAuthTokenResponse.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/responses/OAuthTokenResponse.kt new file mode 100644 index 000000000..0d0d51b01 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/models/responses/OAuthTokenResponse.kt @@ -0,0 +1,22 @@ +package co.nimblehq.template.compose.data.remote.models.responses + +import co.nimblehq.template.compose.domain.models.OAuthTokenModel +import com.squareup.moshi.Json + +data class OAuthTokenResponse( + @Json(name = "accessToken") + val accessToken: String?, + @Json(name = "tokenType") + val tokenType: String?, + @Json(name = "expiresIn") + val expiresIn: Int?, + @Json(name = "refreshToken") + val refreshToken: String?, +) + +internal fun OAuthTokenResponse.toModel() = OAuthTokenModel( + accessToken = accessToken, + tokenType = tokenType, + expiresIn = expiresIn, + refreshToken = refreshToken, +) diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/providers/ApiServiceProvider.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/providers/ApiServiceProvider.kt index ef132278f..14f59931c 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/providers/ApiServiceProvider.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/providers/ApiServiceProvider.kt @@ -1,6 +1,7 @@ package co.nimblehq.template.compose.data.remote.providers import co.nimblehq.template.compose.data.remote.services.ApiService +import co.nimblehq.template.compose.data.remote.services.AuthorizedApiService import retrofit2.Retrofit object ApiServiceProvider { @@ -8,4 +9,8 @@ object ApiServiceProvider { fun getApiService(retrofit: Retrofit): ApiService { return retrofit.create(ApiService::class.java) } + + fun getAuthorizedService(retrofit: Retrofit): AuthorizedApiService { + return retrofit.create(AuthorizedApiService::class.java) + } } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/ApiService.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/ApiService.kt index e0133a168..de90cc6c0 100644 --- a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/ApiService.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/ApiService.kt @@ -1,10 +1,23 @@ package co.nimblehq.template.compose.data.remote.services +import co.nimblehq.template.compose.data.remote.models.requests.RefreshTokenRequest +import co.nimblehq.template.compose.data.remote.models.responses.OAuthTokenResponse import co.nimblehq.template.compose.data.remote.models.responses.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST interface ApiService { @GET("users") suspend fun getResponses(): List + + // TODO This is just an example. Refactor to a real implementation. + @POST("oauth/token") + suspend fun refreshOAuthToken( + @Header("Client-Id") clientId: String, + @Header("Client-Secret") clientSecret: String, + @Body body: RefreshTokenRequest, + ): OAuthTokenResponse } diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/AuthorizedApiService.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/AuthorizedApiService.kt new file mode 100644 index 000000000..3cf343376 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/remote/services/AuthorizedApiService.kt @@ -0,0 +1,5 @@ +package co.nimblehq.template.compose.data.remote.services + +interface AuthorizedApiService { + // Authorized endpoints here +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryImpl.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryImpl.kt new file mode 100644 index 000000000..351ef4bd8 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryImpl.kt @@ -0,0 +1,21 @@ +package co.nimblehq.template.compose.data.repositories + +import co.nimblehq.template.compose.data.extensions.flowTransform +import co.nimblehq.template.compose.data.local.preferences.NetworkEncryptedSharedPreferences +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class AuthPreferenceRepositoryImpl @Inject constructor( + private val networkEncryptedPreferences: NetworkEncryptedSharedPreferences, +) : AuthPreferenceRepository { + + override fun updateAuthenticationStatus(authStatus: AuthStatus): Flow = flowTransform { + networkEncryptedPreferences.authStatus = authStatus + } + + override fun getAuthStatus(): Flow = flowTransform { + networkEncryptedPreferences.authStatus + } +} diff --git a/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryImpl.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryImpl.kt new file mode 100644 index 000000000..30ca1a578 --- /dev/null +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryImpl.kt @@ -0,0 +1,28 @@ +package co.nimblehq.template.compose.data.repositories + +import co.nimblehq.template.compose.data.extensions.flowTransform +import co.nimblehq.template.compose.data.remote.models.requests.toRefreshTokenRequest +import co.nimblehq.template.compose.data.remote.models.responses.toModel +import co.nimblehq.template.compose.data.remote.services.ApiService +import co.nimblehq.template.compose.domain.models.OAuthTokenModel +import co.nimblehq.template.compose.domain.repositories.TokenRepository +import kotlinx.coroutines.flow.Flow +import java.util.Properties + +const val CLIENT_ID_KEY = "CLIENT_ID" +const val CLIENT_SECRET_KEY = "CLIENT_SECRET" + +class TokenRepositoryImpl constructor( + private val apiService: ApiService, + private val apiConfigProperties: Properties, +) : TokenRepository { + + override fun refreshToken(refreshToken: String): Flow = + flowTransform { + apiService.refreshOAuthToken( + clientId = apiConfigProperties.getProperty(CLIENT_ID_KEY), + clientSecret = apiConfigProperties.getProperty(CLIENT_SECRET_KEY), + body = refreshToken.toRefreshTokenRequest() + ).toModel() + } +} diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProvider.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProvider.kt similarity index 79% rename from template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProvider.kt rename to template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProvider.kt index 5d35dafab..c305b7962 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProvider.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProvider.kt @@ -1,4 +1,4 @@ -package co.nimblehq.template.compose.util +package co.nimblehq.template.compose.data.util import kotlinx.coroutines.CoroutineDispatcher diff --git a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProviderImpl.kt b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProviderImpl.kt similarity index 82% rename from template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProviderImpl.kt rename to template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProviderImpl.kt index 54a3904e5..5eb4982a1 100644 --- a/template-compose/app/src/main/java/co/nimblehq/template/compose/util/DispatchersProviderImpl.kt +++ b/template-compose/data/src/main/java/co/nimblehq/template/compose/data/util/DispatchersProviderImpl.kt @@ -1,4 +1,4 @@ -package co.nimblehq.template.compose.util +package co.nimblehq.template.compose.data.util import kotlinx.coroutines.Dispatchers diff --git a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryTest.kt b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryTest.kt new file mode 100644 index 000000000..6e0289495 --- /dev/null +++ b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/AuthPreferenceRepositoryTest.kt @@ -0,0 +1,72 @@ +package co.nimblehq.template.compose.data.repositories + +import co.nimblehq.template.compose.data.local.preferences.NetworkEncryptedSharedPreferences +import co.nimblehq.template.compose.data.remote.models.responses.toModel +import co.nimblehq.template.compose.data.test.MockUtil +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.models.toAuthenticatedStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class AuthPreferenceRepositoryTest { + + private lateinit var repository: AuthPreferenceRepository + + private val mockNetworkSharedPreferences: NetworkEncryptedSharedPreferences = mockk() + + @Before + fun setUp() { + repository = AuthPreferenceRepositoryImpl(mockNetworkSharedPreferences) + every { mockNetworkSharedPreferences.set(any(), any()) } returns Unit + every { mockNetworkSharedPreferences.authStatus = any() } returns Unit + } + + @Test + fun `When calling updateAuthenticationStatus, it updates preference`() = runTest { + val expected = MockUtil.oauthTokenResponse.toModel().toAuthenticatedStatus() + repository.updateAuthenticationStatus(expected).collect() + + verify { + mockNetworkSharedPreferences.authStatus = expected + } + } + + @Test + fun `When calling getAuthStatus, it returns Authenticated if there is AccessToken`() = runTest { + val expected = MockUtil.oauthTokenResponse.toModel().toAuthenticatedStatus() + every { mockNetworkSharedPreferences.authStatus } returns expected + + repository.getAuthStatus().collect { + it shouldBe expected + } + } + + @Test + fun `When calling getAuthStatus, it returns Anonymous status if there is no AccessToken`() = + runTest { + val expected = AuthStatus.Anonymous + every { mockNetworkSharedPreferences.authStatus } returns expected + + repository.getAuthStatus().collect { + it shouldBe expected + } + } + + @Test + fun `When calling getAuthStatus, it returns Anonymous status if the AccessToken is empty`() = + runTest { + val expected = AuthStatus.Anonymous + every { mockNetworkSharedPreferences.authStatus } returns expected + + repository.getAuthStatus().collect { + it shouldBe expected + } + } +} diff --git a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryTest.kt b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryTest.kt new file mode 100644 index 000000000..62bdd4242 --- /dev/null +++ b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/repositories/TokenRepositoryTest.kt @@ -0,0 +1,57 @@ +package co.nimblehq.template.compose.data.repositories + +import co.nimblehq.template.compose.data.remote.models.responses.toModel +import co.nimblehq.template.compose.data.remote.services.ApiService +import co.nimblehq.template.compose.data.test.MockUtil +import co.nimblehq.template.compose.domain.repositories.TokenRepository +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import java.util.Properties + +class TokenRepositoryTest { + + private val mockService: ApiService = mockk() + private val mockApiConfigProperties: Properties = mockk() + private lateinit var repository: TokenRepository + + @Before + fun setUp() { + repository = TokenRepositoryImpl( + apiService = mockService, + apiConfigProperties = mockApiConfigProperties + ) + every { mockApiConfigProperties.getProperty(eq(CLIENT_ID_KEY)) } returns "CLIENT_ID" + every { mockApiConfigProperties.getProperty(eq(CLIENT_SECRET_KEY)) } returns "CLIENT_SECRET" + } + + @Test + fun `When calling refreshToken request successfully, it return success response`() = runTest { + val expected = MockUtil.oauthTokenResponse + + coEvery { mockService.refreshOAuthToken(any(), any(), any()) } returns expected + + repository.refreshToken( + refreshToken = "refreshToken" + ).collect { + it shouldBe expected.toModel() + } + } + + @Test + fun `When calling refreshToken request fails, it returns wrapped error`() = runTest { + val expected = Throwable() + coEvery { mockService.refreshOAuthToken(any(), any(), any()) } throws expected + + repository.refreshToken( + refreshToken = "refreshToken" + ).catch { + it shouldBe expected + }.collect {} + } +} diff --git a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/test/MockUtil.kt b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/test/MockUtil.kt index bab3aa922..d56bf8af4 100644 --- a/template-compose/data/src/test/java/co/nimblehq/template/compose/data/test/MockUtil.kt +++ b/template-compose/data/src/test/java/co/nimblehq/template/compose/data/test/MockUtil.kt @@ -1,6 +1,7 @@ package co.nimblehq.template.compose.data.test import co.nimblehq.template.compose.data.remote.models.responses.ErrorResponse +import co.nimblehq.template.compose.data.remote.models.responses.OAuthTokenResponse import io.mockk.every import io.mockk.mockk import okhttp3.ResponseBody @@ -33,4 +34,11 @@ object MockUtil { val responses = listOf( co.nimblehq.template.compose.data.remote.models.responses.Response(id = 1) ) + + val oauthTokenResponse = OAuthTokenResponse( + accessToken = "accessToken", + tokenType = "tokenType", + expiresIn = null, + refreshToken = "refreshToken", + ) } diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/AuthStatus.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/AuthStatus.kt new file mode 100644 index 000000000..c4941e03d --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/AuthStatus.kt @@ -0,0 +1,23 @@ +package co.nimblehq.template.compose.domain.models + +sealed class AuthStatus { + + data class Authenticated( + val accessToken: String, + val tokenType: String?, + val expiresIn: Int?, + val refreshToken: String?, + ) : AuthStatus() + + data object Anonymous : AuthStatus() +} + +fun OAuthTokenModel.toAuthenticatedStatus() = AuthStatus.Authenticated( + accessToken = accessToken.orEmpty(), + tokenType = tokenType, + expiresIn = expiresIn, + refreshToken = refreshToken.orEmpty() +) + +val AuthStatus.isAuthenticated: Boolean + get() = this is AuthStatus.Authenticated diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/OAuthTokenModel.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/OAuthTokenModel.kt new file mode 100644 index 000000000..cfceb674d --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/models/OAuthTokenModel.kt @@ -0,0 +1,8 @@ +package co.nimblehq.template.compose.domain.models + +data class OAuthTokenModel( + val accessToken: String?, + val tokenType: String?, + val expiresIn: Int?, + val refreshToken: String?, +) diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/AuthPreferenceRepository.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/AuthPreferenceRepository.kt new file mode 100644 index 000000000..b50ac3358 --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/AuthPreferenceRepository.kt @@ -0,0 +1,11 @@ +package co.nimblehq.template.compose.domain.repositories + +import co.nimblehq.template.compose.domain.models.AuthStatus +import kotlinx.coroutines.flow.Flow + +interface AuthPreferenceRepository { + + fun updateAuthenticationStatus(authStatus: AuthStatus): Flow + + fun getAuthStatus(): Flow +} diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/TokenRepository.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/TokenRepository.kt new file mode 100644 index 000000000..a9c6a82ce --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/repositories/TokenRepository.kt @@ -0,0 +1,9 @@ +package co.nimblehq.template.compose.domain.repositories + +import co.nimblehq.template.compose.domain.models.OAuthTokenModel +import kotlinx.coroutines.flow.Flow + +interface TokenRepository { + + fun refreshToken(refreshToken: String): Flow +} diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCase.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCase.kt new file mode 100644 index 000000000..406e22e5e --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCase.kt @@ -0,0 +1,13 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetAuthStatusUseCase @Inject constructor( + private val authPreferenceRepository: AuthPreferenceRepository +) { + + operator fun invoke(): Flow = authPreferenceRepository.getAuthStatus() +} diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCase.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCase.kt new file mode 100644 index 000000000..c9d51b5a1 --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCase.kt @@ -0,0 +1,15 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.models.OAuthTokenModel +import co.nimblehq.template.compose.domain.repositories.TokenRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class RefreshTokenUseCase @Inject constructor( + private val repository: TokenRepository +) { + + operator fun invoke(refreshToken: String): Flow { + return repository.refreshToken(refreshToken) + } +} diff --git a/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCase.kt b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCase.kt new file mode 100644 index 000000000..84adcfc5c --- /dev/null +++ b/template-compose/domain/src/main/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCase.kt @@ -0,0 +1,14 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class UpdateLoginTokensUseCase @Inject constructor( + private val authPreferenceRepository: AuthPreferenceRepository +) { + + operator fun invoke(authStatus: AuthStatus): Flow = + authPreferenceRepository.updateAuthenticationStatus(authStatus) +} diff --git a/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/test/MockUtil.kt b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/test/MockUtil.kt index fc2dcb327..bc654ca46 100644 --- a/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/test/MockUtil.kt +++ b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/test/MockUtil.kt @@ -1,10 +1,18 @@ package co.nimblehq.template.compose.domain.test import co.nimblehq.template.compose.domain.models.Model +import co.nimblehq.template.compose.domain.models.OAuthTokenModel object MockUtil { val models = listOf( Model(id = 1) ) + + val oAuthTokenModel = OAuthTokenModel( + accessToken = "accessToken", + expiresIn = null, + refreshToken = "refreshToken", + tokenType = "tokenType", + ) } diff --git a/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCaseTest.kt b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCaseTest.kt new file mode 100644 index 000000000..5c79a8c04 --- /dev/null +++ b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/GetAuthStatusUseCaseTest.kt @@ -0,0 +1,54 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.models.AuthStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class GetAuthStatusUseCaseTest { + + private lateinit var mockAuthPreferenceRepository: AuthPreferenceRepository + private lateinit var getAuthStatusUseCase: GetAuthStatusUseCase + + @Before + fun setUp() { + mockAuthPreferenceRepository = mockk() + getAuthStatusUseCase = GetAuthStatusUseCase(mockAuthPreferenceRepository) + } + + @Test + fun `When calling request after logging in, it returns Authenticated status`() = + runTest { + val expected = AuthStatus.Authenticated( + accessToken = "accessToken", + tokenType = "tokenType", + expiresIn = 1, + refreshToken = "refreshToken", + ) + every { + mockAuthPreferenceRepository.getAuthStatus() + } returns flowOf(expected) + + getAuthStatusUseCase().collect { + it shouldBe expected + } + } + + @Test + fun `When calling request before logging in, it returns Anonymous status`() = + runTest { + val expected = AuthStatus.Anonymous + every { + mockAuthPreferenceRepository.getAuthStatus() + } returns flowOf(expected) + + getAuthStatusUseCase().collect { + it shouldBe expected + } + } +} diff --git a/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCaseTest.kt b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCaseTest.kt new file mode 100644 index 000000000..916946ed3 --- /dev/null +++ b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/RefreshTokenUseCaseTest.kt @@ -0,0 +1,48 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.repositories.TokenRepository +import co.nimblehq.template.compose.domain.test.MockUtil +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class RefreshTokenUseCaseTest { + + private val mockRepository: TokenRepository = mockk() + private lateinit var useCase: RefreshTokenUseCase + + @Before + fun setUp() { + useCase = RefreshTokenUseCase(mockRepository) + } + + @Test + fun `When calling request successfully, it returns success response`() = runTest { + val expected = MockUtil.oAuthTokenModel + every { mockRepository.refreshToken(any()) } returns flowOf(expected) + + useCase( + refreshToken = "refreshToken" + ).collect { + it shouldBe expected + } + } + + @Test + fun `When calling request fails, it returns wrapped error`() = runTest { + val expected = Exception() + every { mockRepository.refreshToken(any()) } returns flow { throw expected } + + useCase( + refreshToken = "refreshToken" + ).catch { + it shouldBe expected + }.collect {} + } +} diff --git a/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCaseTest.kt b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCaseTest.kt new file mode 100644 index 000000000..93d36807a --- /dev/null +++ b/template-compose/domain/src/test/java/co/nimblehq/template/compose/domain/usecases/UpdateLoginTokensUseCaseTest.kt @@ -0,0 +1,38 @@ +package co.nimblehq.template.compose.domain.usecases + +import co.nimblehq.template.compose.domain.models.toAuthenticatedStatus +import co.nimblehq.template.compose.domain.repositories.AuthPreferenceRepository +import co.nimblehq.template.compose.domain.test.MockUtil +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test + +class UpdateLoginTokensUseCaseTest { + + private lateinit var mockAuthPreferenceRepository: AuthPreferenceRepository + private lateinit var updateLoginTokensUseCase: UpdateLoginTokensUseCase + + @Before + fun setUp() { + mockAuthPreferenceRepository = mockk() + updateLoginTokensUseCase = UpdateLoginTokensUseCase(mockAuthPreferenceRepository) + } + + @Test + fun `When updating login credentials, it calls updateLoginCredentials from AuthPreferenceRepository`() = + runTest { + every { + mockAuthPreferenceRepository.updateAuthenticationStatus(any()) + } returns flowOf(Unit) + + updateLoginTokensUseCase(MockUtil.oAuthTokenModel.toAuthenticatedStatus()) + + verify(exactly = 1) { + mockAuthPreferenceRepository.updateAuthenticationStatus(any()) + } + } +}