Replace AccountManager with EncryptedSharedPreferences

This commit is contained in:
Kirill Kamakin
2022-04-08 20:00:53 +05:00
parent ba28f7d322
commit 7c081c199a
41 changed files with 243 additions and 722 deletions

View File

@@ -6,7 +6,7 @@ interface AuthRepo {
val isAuthorizedFlow: Flow<Boolean>
suspend fun authenticate(username: String, password: String)
suspend fun authenticate(email: String, password: String)
suspend fun getAuthHeader(): String?
@@ -14,5 +14,5 @@ interface AuthRepo {
suspend fun logout()
fun invalidateAuthHeader(header: String)
suspend fun invalidateAuthHeader()
}

View File

@@ -0,0 +1,20 @@
package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage {
val authHeaderFlow: Flow<String?>
suspend fun setAuthHeader(authHeader: String?)
suspend fun getAuthHeader(): String?
suspend fun setEmail(email: String?)
suspend fun getEmail(): String?
suspend fun setPassword(password: String?)
suspend fun getPassword(): String?
}

View File

@@ -23,7 +23,7 @@ class AuthDataSourceImpl @Inject constructor(
override suspend fun authenticate(username: String, password: String): String {
Timber.v("authenticate() called with: username = $username, password = $password")
val authService = authServiceFactory.provideService(needAuth = false)
val authService = authServiceFactory.provideService()
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")

View File

@@ -1,9 +1,9 @@
package gq.kirmanak.mealient.data.auth.impl
import android.accounts.Account
import gq.kirmanak.mealient.data.auth.AuthDataSource
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
@@ -12,71 +12,41 @@ import javax.inject.Singleton
@Singleton
class AuthRepoImpl @Inject constructor(
private val accountManagerInteractor: AccountManagerInteractor,
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = accountManagerInteractor.accountUpdatesFlow()
.map { it.firstOrNull() }
.map { account ->
runCatchingExceptCancel { getAuthToken(account) }
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
.getOrNull()
}.map { it != null }
get() = authStorage.authHeaderFlow.map { it != null }
override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password")
val account = accountManagerInteractor.addAccount(username, password)
runCatchingExceptCancel {
getAuthToken(account) // Try to get token to check if password is correct
}.onFailure {
Timber.e(it, "authenticate: can't authorize")
removeAccount(account) // Remove account with incorrect password
}.onSuccess {
Timber.d("authenticate: successfully authorized")
}.getOrThrow() // Throw error to show it to user
override suspend fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
authDataSource.authenticate(email, password)
.let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) }
authStorage.setEmail(email)
authStorage.setPassword(password)
}
override suspend fun getAuthHeader(): String? = runCatchingExceptCancel {
Timber.v("getAuthHeader() called")
currentAccount()
?.let { getAuthToken(it) }
?.let { AUTH_HEADER_FORMAT.format(it) }
}.onFailure {
Timber.e(it, "getAuthHeader: can't request auth header")
}.getOrNull()
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
private suspend fun getAuthToken(account: Account?): String? {
return account?.let { accountManagerInteractor.getAuthToken(it) }
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
"Auth header is null when it was required"
}
private fun currentAccount(): Account? {
val account = accountManagerInteractor.getAccounts().firstOrNull()
Timber.v("currentAccount() returned: $account")
return account
}
override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override suspend fun logout() {
Timber.v("logout() called")
currentAccount()?.let { removeAccount(it) }
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
}
private suspend fun removeAccount(account: Account) {
Timber.v("removeAccount() called with: account = $account")
accountManagerInteractor.removeAccount(account)
}
override fun invalidateAuthHeader(header: String) {
Timber.v("invalidateAuthHeader() called with: header = $header")
val token = header.substringAfter("Bearer ")
if (token == header) {
Timber.w("invalidateAuthHeader: can't find token in $header")
} else {
accountManagerInteractor.invalidateAuthToken(token)
}
override suspend fun invalidateAuthHeader() {
Timber.v("invalidateAuthHeader() called")
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }
.onFailure { logout() } // Clear all known values to avoid reusing them
}
companion object {

View File

@@ -0,0 +1,64 @@
package gq.kirmanak.mealient.data.auth.impl
import android.content.SharedPreferences
import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.di.AuthModule.Companion.ENCRYPTED
import gq.kirmanak.mealient.extensions.prefsChangeFlow
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class AuthStorageImpl @Inject constructor(
@Named(ENCRYPTED) private val sharedPreferences: SharedPreferences,
) : AuthStorage {
override val authHeaderFlow: Flow<String?>
get() = sharedPreferences
.prefsChangeFlow { getString(AUTH_HEADER_KEY, null) }
.distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
override suspend fun setAuthHeader(authHeader: String?) {
putString(AUTH_HEADER_KEY, authHeader)
}
override suspend fun getAuthHeader(): String? = getString(AUTH_HEADER_KEY)
override suspend fun setEmail(email: String?) = putString(EMAIL_KEY, email)
override suspend fun getEmail(): String? = getString(EMAIL_KEY)
override suspend fun setPassword(password: String?) = putString(PASSWORD_KEY, password)
override suspend fun getPassword(): String? = getString(PASSWORD_KEY)
private suspend fun putString(
key: String,
value: String?
) = withContext(singleThreadDispatcher) {
Timber.v("putString() called with: key = $key, value = $value")
sharedPreferences.edit {
value?.let { putString(key, value) } ?: remove(key)
}
}
private suspend fun getString(key: String) = withContext(singleThreadDispatcher) {
val result = sharedPreferences.getString(key, null)
Timber.v("getString() called with: key = $key, returned: $result")
result
}
companion object {
private const val AUTH_HEADER_KEY = "authHeader"
private const val EMAIL_KEY = "email"
private const val PASSWORD_KEY = "password"
}
}

View File

@@ -19,7 +19,7 @@ class AuthenticationInterceptor @Inject constructor(
val currentHeader = authHeader ?: return chain.proceed(chain.request())
val response = proceedWithAuthHeader(chain, currentHeader)
if (listOf(401, 403).contains(response.code)) {
authRepo.invalidateAuthHeader(currentHeader)
runBlocking { authRepo.invalidateAuthHeader() }
} else {
return response
}

View File

@@ -1,34 +1,26 @@
package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import gq.kirmanak.mealient.di.AUTH_OK_HTTP
import gq.kirmanak.mealient.di.NO_AUTH_OK_HTTP
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Named
import javax.inject.Singleton
@Singleton
class RetrofitBuilder @Inject constructor(
@Named(AUTH_OK_HTTP) private val authOkHttpClient: OkHttpClient,
@Named(NO_AUTH_OK_HTTP) private val noAuthOkHttpClient: OkHttpClient,
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String, needAuth: Boolean): Retrofit {
fun buildRetrofit(baseUrl: String): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
val client = if (needAuth) authOkHttpClient else noAuthOkHttpClient
return Retrofit.Builder()
.baseUrl(baseUrl)
.client(client)
.client(okHttpClient)
.addConverterFactory(converterFactory)
.build()
}

View File

@@ -13,28 +13,21 @@ class RetrofitServiceFactory<T>(
private val baseURLStorage: BaseURLStorage,
) : ServiceFactory<T> {
private val cache: MutableMap<ServiceParams, T> = mutableMapOf()
private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService(
baseUrl: String?,
needAuth: Boolean,
): T = runCatchingExceptCancel {
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
val url = baseUrl ?: baseURLStorage.requireBaseURL()
val params = ServiceParams(url, needAuth)
synchronized(cache) { cache[params] ?: createService(params, serviceClass) }
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}.getOrElse {
Timber.e(it, "provideService: can't provide service for $baseUrl")
throw NetworkError.MalformedUrl(it)
}
private fun createService(serviceParams: ServiceParams, serviceClass: Class<T>): T {
Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}")
val (url, needAuth) = serviceParams
val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass)
cache[serviceParams] = service
private fun createService(url: String, serviceClass: Class<T>): T {
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service
}
data class ServiceParams(val baseUrl: String, val needAuth: Boolean)
}

View File

@@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network
interface ServiceFactory<T> {
suspend fun provideService(baseUrl: String? = null, needAuth: Boolean = true): T
suspend fun provideService(baseUrl: String? = null): T
}