Replace AccountManager with EncryptedSharedPreferences
This commit is contained in:
@@ -154,6 +154,9 @@ dependencies {
|
||||
// https://developer.android.com/topic/libraries/architecture/datastore
|
||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
||||
|
||||
// https://developer.android.com/topic/security/data#include-library
|
||||
implementation "androidx.security:security-crypto:1.0.0"
|
||||
|
||||
// https://github.com/junit-team/junit4/releases
|
||||
testImplementation "junit:junit:4.13.2"
|
||||
|
||||
|
||||
@@ -15,27 +15,16 @@
|
||||
android:roundIcon="@mipmap/ic_launcher"
|
||||
android:supportsRtl="true"
|
||||
tools:ignore="UnusedAttribute"
|
||||
android:theme="@style/Theme.Mealient">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
android:theme="@style/Theme.Mealient">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".service.auth.AuthenticationService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.accounts.AccountAuthenticator" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.accounts.AccountAuthenticator"
|
||||
android:resource="@xml/account_authenticator" />
|
||||
</service>
|
||||
</application>
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -5,6 +5,8 @@ import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation.findNavController
|
||||
import com.google.android.material.shape.CornerFamily
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -71,9 +73,11 @@ class MainActivity : AppCompatActivity() {
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
Timber.v("onOptionsItemSelected() called with: item = $item")
|
||||
val result = when (item.itemId) {
|
||||
R.id.logout, R.id.login -> {
|
||||
// When user clicks logout they don't want to be authorized
|
||||
authViewModel.authRequested = item.itemId == R.id.login
|
||||
R.id.login -> {
|
||||
navigateToLogin()
|
||||
true
|
||||
}
|
||||
R.id.logout -> {
|
||||
authViewModel.logout()
|
||||
true
|
||||
}
|
||||
@@ -81,4 +85,9 @@ class MainActivity : AppCompatActivity() {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun navigateToLogin() {
|
||||
Timber.v("navigateToLogin() called")
|
||||
findNavController(binding.navHost.id).navigate("mealient://authenticate".toUri())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,25 +2,29 @@ package gq.kirmanak.mealient.di
|
||||
|
||||
import android.accounts.AccountManager
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import gq.kirmanak.mealient.R
|
||||
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.data.auth.impl.AuthDataSourceImpl
|
||||
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
||||
import gq.kirmanak.mealient.data.auth.impl.AuthService
|
||||
import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl
|
||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
|
||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl
|
||||
import gq.kirmanak.mealient.service.auth.AccountParameters
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -28,13 +32,17 @@ import javax.inject.Singleton
|
||||
interface AuthModule {
|
||||
|
||||
companion object {
|
||||
const val ENCRYPTED = "encrypted"
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthServiceFactory(
|
||||
retrofitBuilder: RetrofitBuilder,
|
||||
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||
json: Json,
|
||||
baseURLStorage: BaseURLStorage,
|
||||
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
||||
): ServiceFactory<AuthService> {
|
||||
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@@ -44,10 +52,20 @@ interface AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountType(@ApplicationContext context: Context) = AccountParameters(
|
||||
accountType = context.getString(R.string.account_type),
|
||||
authTokenType = context.getString(R.string.auth_token_type),
|
||||
)
|
||||
@Named(ENCRYPTED)
|
||||
fun provideEncryptedSharedPreferences(
|
||||
@ApplicationContext applicationContext: Context,
|
||||
): SharedPreferences {
|
||||
val keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC
|
||||
val mainKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec)
|
||||
return EncryptedSharedPreferences.create(
|
||||
ENCRYPTED,
|
||||
mainKeyAlias,
|
||||
applicationContext,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Binds
|
||||
@@ -60,7 +78,5 @@ interface AuthModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindAccountManagerInteractor(
|
||||
accountManagerInteractorImpl: AccountManagerInteractorImpl
|
||||
): AccountManagerInteractor
|
||||
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import gq.kirmanak.mealient.data.baseurl.*
|
||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -20,9 +23,12 @@ interface BaseURLModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVersionServiceFactory(
|
||||
retrofitBuilder: RetrofitBuilder,
|
||||
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||
json: Json,
|
||||
baseURLStorage: BaseURLStorage,
|
||||
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
||||
): ServiceFactory<VersionService> {
|
||||
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||
}
|
||||
}
|
||||
|
||||
@Binds
|
||||
|
||||
@@ -19,6 +19,9 @@ import gq.kirmanak.mealient.data.recipes.impl.RecipeRepoImpl
|
||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
|
||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
||||
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@@ -46,9 +49,12 @@ interface RecipeModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecipeServiceFactory(
|
||||
retrofitBuilder: RetrofitBuilder,
|
||||
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||
json: Json,
|
||||
baseURLStorage: BaseURLStorage,
|
||||
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
||||
): ServiceFactory<RecipeService> {
|
||||
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -4,6 +4,7 @@ import androidx.activity.OnBackPressedDispatcher
|
||||
import androidx.activity.addCallback
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -33,4 +34,8 @@ fun OnBackPressedDispatcher.backPressedFlow(): Flow<Unit> = callbackFlow {
|
||||
inline fun <T> Fragment.collectWithViewLifecycle(
|
||||
flow: Flow<T>,
|
||||
crossinline collector: suspend (T) -> Unit,
|
||||
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) }
|
||||
) = launchWithViewLifecycle { flow.collect(collector) }
|
||||
|
||||
fun Fragment.launchWithViewLifecycle(
|
||||
block: suspend CoroutineScope.() -> Unit,
|
||||
) = viewLifecycleOwner.lifecycleScope.launch(block = block)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package gq.kirmanak.mealient.extensions
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Build
|
||||
import android.view.View
|
||||
import android.view.WindowInsets
|
||||
@@ -106,4 +107,17 @@ fun EditText.checkIfInputIsEmpty(
|
||||
suspend fun EditText.waitUntilNotEmpty() {
|
||||
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
|
||||
Timber.v("waitUntilNotEmpty() returned")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
fun <T> SharedPreferences.prefsChangeFlow(
|
||||
valueReader: SharedPreferences.() -> T,
|
||||
): Flow<T> = callbackFlow {
|
||||
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, _ ->
|
||||
val value = prefs.valueReader()
|
||||
trySend(value).logErrors("prefsChangeFlow")
|
||||
}
|
||||
trySend(valueReader())
|
||||
registerOnSharedPreferenceChangeListener(listener)
|
||||
awaitClose { unregisterOnSharedPreferenceChangeListener(listener) }
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.*
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||
import gq.kirmanak.mealient.service.auth.AuthenticatorException.*
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccountAuthenticatorImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val authDataSource: AuthDataSource,
|
||||
private val accountParameters: AccountParameters,
|
||||
private val accountManager: AccountManager,
|
||||
) : AbstractAccountAuthenticator(context) {
|
||||
|
||||
private val accountType: String
|
||||
get() = accountParameters.accountType
|
||||
private val authTokenType: String
|
||||
get() = accountParameters.authTokenType
|
||||
|
||||
override fun getAuthToken(
|
||||
response: AccountAuthenticatorResponse,
|
||||
account: Account,
|
||||
authTokenType: String,
|
||||
options: Bundle?
|
||||
): Bundle {
|
||||
Timber.v("getAuthToken() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
|
||||
|
||||
val password = try {
|
||||
checkAccountType(account.type)
|
||||
checkAuthTokenType(authTokenType)
|
||||
accountManager.getPassword(account) ?: throw AccountNotFound(account)
|
||||
} catch (e: AuthenticatorException) {
|
||||
Timber.e(e, "getAuthToken: validation failure")
|
||||
return e.bundle
|
||||
}
|
||||
|
||||
val token = runCatchingExceptCancel {
|
||||
runBlocking {
|
||||
withTimeout(10000) {
|
||||
authDataSource.authenticate(account.name, password)
|
||||
}
|
||||
}
|
||||
}.getOrElse {
|
||||
return when (it) {
|
||||
is NetworkError.NotMealie -> NotMealie.bundle
|
||||
is NetworkError.Unauthorized -> Unauthorized.bundle
|
||||
else -> throw NetworkErrorException(it)
|
||||
}
|
||||
}
|
||||
|
||||
return Bundle().apply {
|
||||
putString(AccountManager.KEY_ACCOUNT_NAME, account.name)
|
||||
putString(AccountManager.KEY_ACCOUNT_TYPE, accountType)
|
||||
putString(AccountManager.KEY_AUTHTOKEN, token)
|
||||
}
|
||||
}
|
||||
|
||||
// region Unsupported operations
|
||||
override fun confirmCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
options: Bundle?
|
||||
): Bundle {
|
||||
Timber.v("confirmCredentials() called with: response = $response, account = $account, options = $options")
|
||||
return UnsupportedOperation("confirmCredentials").bundle
|
||||
}
|
||||
|
||||
override fun addAccount(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
authTokenType: String,
|
||||
requiredFeatures: Array<out String>?,
|
||||
options: Bundle?,
|
||||
): Bundle {
|
||||
Timber.v("addAccount() called with: response = $response, accountType = $accountType, authTokenType = $authTokenType, requiredFeatures = $requiredFeatures, options = $options")
|
||||
return UnsupportedOperation("addAccount").bundle
|
||||
}
|
||||
|
||||
override fun editProperties(
|
||||
response: AccountAuthenticatorResponse,
|
||||
accountType: String,
|
||||
): Bundle? {
|
||||
Timber.v("editProperties() called with: response = $response, accountType = $accountType")
|
||||
response.onError(
|
||||
AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
|
||||
"editProperties is not supported"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
override fun getAuthTokenLabel(authTokenType: String?): String? {
|
||||
Timber.v("getAuthTokenLabel() called with: authTokenType = $authTokenType")
|
||||
return null
|
||||
}
|
||||
|
||||
override fun updateCredentials(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
authTokenType: String?,
|
||||
options: Bundle?
|
||||
): Bundle {
|
||||
Timber.v("updateCredentials() called with: response = $response, account = $account, authTokenType = $authTokenType, options = $options")
|
||||
return UnsupportedOperation("updateCredentials").bundle
|
||||
}
|
||||
|
||||
override fun hasFeatures(
|
||||
response: AccountAuthenticatorResponse?,
|
||||
account: Account?,
|
||||
features: Array<out String>?
|
||||
): Bundle {
|
||||
Timber.v("hasFeatures() called with: response = $response, account = $account, features = $features")
|
||||
return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, true) }
|
||||
}
|
||||
// end region
|
||||
|
||||
private fun checkAccountType(accountType: String) {
|
||||
if (accountType != this.accountType) {
|
||||
throw UnsupportedAccountType(accountType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkAuthTokenType(authTokenType: String) {
|
||||
if (authTokenType != this.authTokenType) {
|
||||
throw UnsupportedAuthTokenType(authTokenType)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface AccountManagerInteractor {
|
||||
|
||||
fun getAccounts(): Array<Account>
|
||||
|
||||
suspend fun addAccount(email: String, password: String): Account
|
||||
|
||||
suspend fun getAuthToken(account: Account): String
|
||||
|
||||
fun accountUpdatesFlow(): Flow<Array<Account>>
|
||||
|
||||
suspend fun removeAccount(account: Account)
|
||||
|
||||
fun invalidateAuthToken(token: String)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class AccountManagerInteractorImpl @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val accountParameters: AccountParameters,
|
||||
) : AccountManagerInteractor {
|
||||
|
||||
private val accountType: String
|
||||
get() = accountParameters.accountType
|
||||
private val authTokenType: String
|
||||
get() = accountParameters.authTokenType
|
||||
|
||||
override fun getAccounts(): Array<Account> {
|
||||
Timber.v("getAccounts() called")
|
||||
val accounts = accountManager.getAccountsByType(accountType)
|
||||
Timber.v("getAccounts() returned: ${accounts.contentToString()}")
|
||||
return accounts
|
||||
}
|
||||
|
||||
override suspend fun addAccount(email: String, password: String): Account {
|
||||
Timber.v("addAccount() called with: email = $email, password = $password")
|
||||
val account = Account(email, accountType)
|
||||
removeAccount(account) // Remove account if it was created earlier
|
||||
accountManager.addAccountExplicitly(account, password, null)
|
||||
return account
|
||||
}
|
||||
|
||||
override suspend fun getAuthToken(account: Account): String {
|
||||
Timber.v("getAuthToken() called with: account = $account")
|
||||
val bundle = accountManager.getAuthToken(
|
||||
account,
|
||||
authTokenType,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
).await()
|
||||
val receivedAccount = bundle.toAccount()
|
||||
check(account == receivedAccount) {
|
||||
"Received account ($receivedAccount) differs from requested ($account)"
|
||||
}
|
||||
val token = bundle.authToken()
|
||||
Timber.v("getAuthToken() returned: $token")
|
||||
return token
|
||||
}
|
||||
|
||||
override fun accountUpdatesFlow(): Flow<Array<Account>> {
|
||||
Timber.v("accountUpdatesFlow() called")
|
||||
return accountManager.accountUpdatesFlow(accountType)
|
||||
}
|
||||
|
||||
override suspend fun removeAccount(account: Account) {
|
||||
Timber.v("removeAccount() called with: account = $account")
|
||||
val bundle = accountManager.removeAccount(account, null, null, null).await()
|
||||
Timber.d("removeAccount: result is ${bundle.result()}")
|
||||
}
|
||||
|
||||
override fun invalidateAuthToken(token: String) {
|
||||
Timber.v("resetAuthToken() called with: token = $token")
|
||||
accountManager.invalidateAuthToken(accountType, token)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
data class AccountParameters(
|
||||
val accountType: String,
|
||||
val authTokenType: String,
|
||||
)
|
||||
@@ -1,53 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.accounts.AccountManager.*
|
||||
import android.accounts.AccountManagerFuture
|
||||
import android.accounts.OnAccountsUpdateListener
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import gq.kirmanak.mealient.extensions.logErrors
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
|
||||
internal suspend fun <T> AccountManagerFuture<T>.await(): T = withContext(Dispatchers.IO) { result }
|
||||
|
||||
internal fun Bundle.toAccount(): Account = Account(accountName(), accountType())
|
||||
|
||||
internal fun Bundle.accountType(): String = string(KEY_ACCOUNT_TYPE) { "Account type is null" }
|
||||
|
||||
internal fun Bundle.accountName(): String = string(KEY_ACCOUNT_NAME) { "Account name is null" }
|
||||
|
||||
internal fun Bundle.authToken(): String = string(KEY_AUTHTOKEN) { "Auth token is null" }
|
||||
|
||||
internal fun Bundle.result(): Boolean = getBoolean(KEY_BOOLEAN_RESULT)
|
||||
|
||||
private fun Bundle.string(key: String, error: () -> String) = checkNotNull(getString(key), error)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal fun AccountManager.accountUpdatesFlow(vararg types: String): Flow<Array<Account>> =
|
||||
callbackFlow {
|
||||
Timber.v("accountUpdatesFlow() called")
|
||||
val listener = OnAccountsUpdateListener { accounts ->
|
||||
Timber.d("accountUpdatesFlow: updated accounts = ${accounts.contentToString()}")
|
||||
val filtered = accounts.filter { types.contains(it.type) }.toTypedArray()
|
||||
Timber.d("accountUpdatesFlow: filtered accounts = ${filtered.contentToString()}")
|
||||
trySendBlocking(filtered).logErrors("accountUpdatesFlow")
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
addOnAccountsUpdatedListener(listener, null, true, types)
|
||||
} else {
|
||||
addOnAccountsUpdatedListener(listener, null, true)
|
||||
}
|
||||
awaitClose {
|
||||
Timber.d("accountUpdatesFlow: cancelled")
|
||||
removeOnAccountsUpdatedListener(listener)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AuthenticationService : Service() {
|
||||
|
||||
@Inject
|
||||
lateinit var accountAuthenticatorImpl: AccountAuthenticatorImpl
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = accountAuthenticatorImpl.iBinder
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package gq.kirmanak.mealient.service.auth
|
||||
|
||||
import android.accounts.Account
|
||||
import android.accounts.AccountManager
|
||||
import android.os.Bundle
|
||||
|
||||
sealed class AuthenticatorException(
|
||||
val bundle: Bundle
|
||||
) : RuntimeException() {
|
||||
|
||||
constructor(errorCode: Int, errorMessage: String) : this(
|
||||
Bundle().apply {
|
||||
putInt(AccountManager.KEY_ERROR_CODE, errorCode)
|
||||
putString(AccountManager.KEY_ERROR_MESSAGE, errorMessage)
|
||||
}
|
||||
)
|
||||
|
||||
class UnsupportedAuthTokenType(received: String?) : AuthenticatorException(
|
||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
||||
errorMessage = "Received auth token type = $received"
|
||||
)
|
||||
|
||||
class UnsupportedAccountType(received: String?) : AuthenticatorException(
|
||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
||||
errorMessage = "Received account type = $received"
|
||||
)
|
||||
|
||||
class UnsupportedOperation(operation: String) : AuthenticatorException(
|
||||
errorCode = AccountManager.ERROR_CODE_UNSUPPORTED_OPERATION,
|
||||
errorMessage = "$operation is not supported"
|
||||
)
|
||||
|
||||
class AccountNotFound(account: Account) : AuthenticatorException(
|
||||
errorCode = AccountManager.ERROR_CODE_BAD_ARGUMENTS,
|
||||
errorMessage = "$account not found"
|
||||
)
|
||||
|
||||
object Unauthorized : AuthenticatorException(
|
||||
errorCode = ErrorCode.Unauthorized.ordinal,
|
||||
errorMessage = "E-mail or password weren't correct"
|
||||
)
|
||||
|
||||
object NotMealie : AuthenticatorException(
|
||||
errorCode = ErrorCode.NotMealie.ordinal,
|
||||
errorMessage = "Base URL must be pointing at a non Mealie server"
|
||||
)
|
||||
|
||||
enum class ErrorCode { NotMealie, Unauthorized; }
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package gq.kirmanak.mealient.ui.addaccount
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.PersistableBundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.R
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AddAccountActivity : AppCompatActivity() {
|
||||
|
||||
private val viewModel by viewModels<AddAccountViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) {
|
||||
super.onCreate(savedInstanceState, persistentState)
|
||||
supportActionBar?.title = getString(R.string.app_name)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package gq.kirmanak.mealient.ui.addaccount
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
|
||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
class AddAccountFragment : Fragment(R.layout.fragment_authentication) {
|
||||
|
||||
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
|
||||
private val viewModel by activityViewModels<AddAccountViewModel>()
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||
binding.button.setOnClickListener { onLoginClicked() }
|
||||
}
|
||||
|
||||
private fun onLoginClicked(): Unit = with(binding) {
|
||||
Timber.v("onLoginClicked() called")
|
||||
|
||||
val email: String = emailInput.checkIfInputIsEmpty(
|
||||
inputLayout = emailInputLayout,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
stringId = R.string.fragment_authentication_email_input_empty,
|
||||
) ?: return
|
||||
|
||||
val pass: String = passwordInput.checkIfInputIsEmpty(
|
||||
inputLayout = passwordInputLayout,
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
stringId = R.string.fragment_authentication_password_input_empty,
|
||||
trim = false,
|
||||
) ?: return
|
||||
|
||||
button.isClickable = false
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
onAuthenticationResult(viewModel.authenticate(email, pass))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAuthenticationResult(result: Result<Unit>) {
|
||||
Timber.v("onAuthenticationResult() called with: result = $result")
|
||||
if (result.isSuccess) {
|
||||
TODO("Implement authentication success")
|
||||
}
|
||||
|
||||
binding.passwordInputLayout.error = when (result.exceptionOrNull()) {
|
||||
is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect)
|
||||
else -> null
|
||||
}
|
||||
|
||||
binding.button.isClickable = true
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package gq.kirmanak.mealient.ui.addaccount
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class AddAccountViewModel(
|
||||
private val authRepo: AuthRepo,
|
||||
) : ViewModel() {
|
||||
|
||||
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
|
||||
Timber.v("authenticate() called with: username = $username, password = $password")
|
||||
authRepo.authenticate(username, password)
|
||||
}.onFailure {
|
||||
Timber.e(it, "authenticate: can't authenticate")
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import android.view.View
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import by.kirich1409.viewbindingdelegate.viewBinding
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
@@ -13,8 +12,7 @@ import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
|
||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||
import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed
|
||||
import kotlinx.coroutines.launch
|
||||
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -22,12 +20,6 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
|
||||
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
|
||||
private val viewModel by activityViewModels<AuthenticationViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
|
||||
executeOnceOnBackPressed { viewModel.authRequested = false }
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||
@@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
|
||||
) ?: return
|
||||
|
||||
button.isClickable = false
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
onAuthenticationResult(viewModel.authenticate(email, pass))
|
||||
}
|
||||
launchWithViewLifecycle { onAuthenticationResult(viewModel.authenticate(email, pass)) }
|
||||
}
|
||||
|
||||
private fun onAuthenticationResult(result: Result<Unit>) {
|
||||
|
||||
@@ -4,22 +4,19 @@ import timber.log.Timber
|
||||
|
||||
enum class AuthenticationState {
|
||||
AUTHORIZED,
|
||||
AUTH_REQUESTED,
|
||||
UNAUTHORIZED,
|
||||
UNKNOWN;
|
||||
|
||||
companion object {
|
||||
|
||||
fun determineState(
|
||||
isLoginRequested: Boolean,
|
||||
showLoginButton: Boolean,
|
||||
isAuthorized: Boolean,
|
||||
): AuthenticationState {
|
||||
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
|
||||
Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
|
||||
val result = when {
|
||||
!showLoginButton -> UNKNOWN
|
||||
isAuthorized -> AUTHORIZED
|
||||
isLoginRequested -> AUTH_REQUESTED
|
||||
else -> UNAUTHORIZED
|
||||
}
|
||||
Timber.v("determineState() returned: $result")
|
||||
|
||||
@@ -18,21 +18,18 @@ class AuthenticationViewModel @Inject constructor(
|
||||
private val authRepo: AuthRepo,
|
||||
) : ViewModel() {
|
||||
|
||||
private val authRequestsFlow = MutableStateFlow(false)
|
||||
private val showLoginButtonFlow = MutableStateFlow(false)
|
||||
private val authenticationStateFlow = combine(
|
||||
authRequestsFlow,
|
||||
showLoginButtonFlow,
|
||||
authRepo.isAuthorizedFlow,
|
||||
AuthenticationState::determineState
|
||||
)
|
||||
val authenticationStateLive: LiveData<AuthenticationState>
|
||||
get() = authenticationStateFlow.asLiveData()
|
||||
var authRequested: Boolean by authRequestsFlow::value
|
||||
var showLoginButton: Boolean by showLoginButtonFlow::value
|
||||
|
||||
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
|
||||
authRepo.authenticate(username, password)
|
||||
suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel {
|
||||
authRepo.authenticate(email, password)
|
||||
}.onFailure {
|
||||
Timber.e(it, "authenticate: can't authenticate")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import gq.kirmanak.mealient.R
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
|
||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
@@ -22,7 +23,6 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
|
||||
binding.button.setOnClickListener(::onProceedClick)
|
||||
}
|
||||
|
||||
@@ -33,16 +33,16 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
||||
lifecycleOwner = viewLifecycleOwner,
|
||||
stringId = R.string.fragment_baseurl_url_input_empty,
|
||||
) ?: return
|
||||
viewModel.saveBaseUrl(url)
|
||||
launchWithViewLifecycle { onCheckURLResult(viewModel.saveBaseUrl(url)) }
|
||||
}
|
||||
|
||||
private fun updateState(baseURLScreenState: BaseURLScreenState) {
|
||||
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
|
||||
if (baseURLScreenState.navigateNext) {
|
||||
private fun onCheckURLResult(result: Result<Unit>) {
|
||||
Timber.v("onCheckURLResult() called with: result = $result")
|
||||
if (result.isSuccess) {
|
||||
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
|
||||
return
|
||||
}
|
||||
binding.urlInputLayout.error = when (val exception = baseURLScreenState.error) {
|
||||
binding.urlInputLayout.error = when (val exception = result.exceptionOrNull()) {
|
||||
is NetworkError.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
|
||||
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
|
||||
is NetworkError.MalformedUrl -> {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package gq.kirmanak.mealient.ui.baseurl
|
||||
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
|
||||
data class BaseURLScreenState(
|
||||
val error: NetworkError? = null,
|
||||
val navigateNext: Boolean = false,
|
||||
)
|
||||
@@ -1,14 +1,10 @@
|
||||
package gq.kirmanak.mealient.ui.baseurl
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import kotlinx.coroutines.launch
|
||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -18,35 +14,21 @@ class BaseURLViewModel @Inject constructor(
|
||||
private val versionDataSource: VersionDataSource,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _screenState = MutableLiveData(BaseURLScreenState())
|
||||
var currentScreenState: BaseURLScreenState
|
||||
get() = _screenState.value!!
|
||||
private set(value) {
|
||||
_screenState.value = value
|
||||
}
|
||||
val screenState: LiveData<BaseURLScreenState>
|
||||
get() = _screenState
|
||||
|
||||
fun saveBaseUrl(baseURL: String) {
|
||||
suspend fun saveBaseUrl(baseURL: String): Result<Unit> {
|
||||
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
|
||||
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
|
||||
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL)
|
||||
viewModelScope.launch { checkBaseURL(url) }
|
||||
return checkBaseURL(url)
|
||||
}
|
||||
|
||||
private suspend fun checkBaseURL(baseURL: String) {
|
||||
private suspend fun checkBaseURL(baseURL: String): Result<Unit> {
|
||||
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
|
||||
val version = try {
|
||||
val result = runCatchingExceptCancel {
|
||||
// If it returns proper version info then it must be a Mealie
|
||||
versionDataSource.getVersionInfo(baseURL)
|
||||
} catch (e: NetworkError) {
|
||||
Timber.e(e, "checkBaseURL: can't get version info")
|
||||
currentScreenState = BaseURLScreenState(e, false)
|
||||
return
|
||||
}
|
||||
Timber.d("checkBaseURL: version is $version")
|
||||
baseURLStorage.storeBaseURL(baseURL)
|
||||
currentScreenState = BaseURLScreenState(null, true)
|
||||
return result.map { }
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -14,7 +14,6 @@ import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
|
||||
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
|
||||
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
|
||||
import gq.kirmanak.mealient.extensions.refreshRequestFlow
|
||||
import gq.kirmanak.mealient.ui.auth.AuthenticationState
|
||||
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -24,19 +23,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
||||
private val viewModel by viewModels<RecipeViewModel>()
|
||||
private val authViewModel by activityViewModels<AuthenticationViewModel>()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
|
||||
authViewModel.authenticationStateLive.observe(this, ::onAuthStateChange)
|
||||
}
|
||||
|
||||
private fun onAuthStateChange(authenticationState: AuthenticationState) {
|
||||
Timber.v("onAuthStateChange() called with: authenticationState = $authenticationState")
|
||||
if (authenticationState == AuthenticationState.AUTH_REQUESTED) {
|
||||
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||
|
||||
@@ -9,15 +9,16 @@
|
||||
android:id="@+id/authenticationFragment"
|
||||
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
|
||||
android:label="AuthenticationFragment"
|
||||
tools:layout="@layout/fragment_authentication" />
|
||||
tools:layout="@layout/fragment_authentication">
|
||||
<deepLink
|
||||
android:id="@+id/deepLink"
|
||||
app:uri="mealient://authenticate" />
|
||||
</fragment>
|
||||
<fragment
|
||||
android:id="@+id/recipesFragment"
|
||||
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
|
||||
android:label="fragment_recipes"
|
||||
tools:layout="@layout/fragment_recipes">
|
||||
<action
|
||||
android:id="@+id/action_recipesFragment_to_authenticationFragment"
|
||||
app:destination="@id/authenticationFragment" />
|
||||
<action
|
||||
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
||||
app:destination="@id/recipeInfoFragment" />
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:accountType="@string/account_type"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name" />
|
||||
@@ -32,7 +32,7 @@ class AuthDataSourceImplTest {
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
|
||||
coEvery { authServiceFactory.provideService(any(), eq(false)) } returns authService
|
||||
coEvery { authServiceFactory.provideService(any()) } returns authService
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -72,7 +72,7 @@ class AuthDataSourceImplTest {
|
||||
@Test(expected = MalformedUrl::class)
|
||||
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
|
||||
coEvery {
|
||||
authServiceFactory.provideService(any(), eq(false))
|
||||
authServiceFactory.provideService(any())
|
||||
} throws MalformedUrl(RuntimeException())
|
||||
callAuthenticate()
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package gq.kirmanak.mealient.data.auth.impl
|
||||
|
||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
|
||||
import io.mockk.MockKAnnotations
|
||||
import io.mockk.impl.annotations.MockK
|
||||
import org.junit.Before
|
||||
|
||||
class AuthRepoImplTest {
|
||||
|
||||
@MockK
|
||||
lateinit var accountManagerInteractor: AccountManagerInteractor
|
||||
|
||||
lateinit var subject: AuthRepoImpl
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
subject = AuthRepoImpl(accountManagerInteractor)
|
||||
}
|
||||
|
||||
// TODO write the actual tests
|
||||
}
|
||||
@@ -66,7 +66,7 @@ class AuthenticationInterceptorTest {
|
||||
|
||||
coVerifySequence {
|
||||
authRepo.getAuthHeader()
|
||||
authRepo.invalidateAuthHeader(TEST_AUTH_HEADER)
|
||||
authRepo.invalidateAuthHeader()
|
||||
authRepo.getAuthHeader()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class RetrofitServiceFactoryTest {
|
||||
fun setUp() {
|
||||
MockKAnnotations.init(this)
|
||||
subject = retrofitBuilder.createServiceFactory(baseURLStorage)
|
||||
coEvery { retrofitBuilder.buildRetrofit(any(), eq(true)) } returns retrofit
|
||||
coEvery { retrofitBuilder.buildRetrofit(any()) } returns retrofit
|
||||
every { retrofit.create(eq(VersionService::class.java)) } returns versionService
|
||||
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
|
||||
}
|
||||
@@ -53,7 +53,7 @@ class RetrofitServiceFactoryTest {
|
||||
fun `when provideService called twice then builder called once`() = runTest {
|
||||
subject.provideService()
|
||||
subject.provideService()
|
||||
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) }
|
||||
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -61,8 +61,8 @@ class RetrofitServiceFactoryTest {
|
||||
subject.provideService()
|
||||
subject.provideService("new url")
|
||||
coVerifyAll {
|
||||
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true))
|
||||
retrofitBuilder.buildRetrofit(eq("new url"), eq(true))
|
||||
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL))
|
||||
retrofitBuilder.buildRetrofit(eq("new url"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
package gq.kirmanak.mealient.ui.baseurl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
||||
import gq.kirmanak.mealient.data.baseurl.VersionInfo
|
||||
import gq.kirmanak.mealient.data.network.NetworkError
|
||||
import gq.kirmanak.mealient.test.AuthImplTestData.TEST_BASE_URL
|
||||
import gq.kirmanak.mealient.test.RobolectricTest
|
||||
import io.mockk.MockKAnnotations
|
||||
@@ -34,35 +32,6 @@ class BaseURLViewModelTest : RobolectricTest() {
|
||||
subject = BaseURLViewModel(baseURLStorage, versionDataSource)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when initialized then error is null`() {
|
||||
assertThat(subject.currentScreenState.error).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when initialized then navigateNext is false`() {
|
||||
assertThat(subject.currentScreenState.navigateNext).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when saveBaseUrl and getVersionInfo throws then state is correct`() = runTest {
|
||||
val error = NetworkError.Unauthorized(RuntimeException())
|
||||
coEvery { versionDataSource.getVersionInfo(eq(TEST_BASE_URL)) } throws error
|
||||
subject.saveBaseUrl(TEST_BASE_URL)
|
||||
advanceUntilIdle()
|
||||
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(error, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when saveBaseUrl and getVersionInfo returns result then state is correct`() = runTest {
|
||||
coEvery {
|
||||
versionDataSource.getVersionInfo(eq(TEST_BASE_URL))
|
||||
} returns VersionInfo(true, "0.5.6", true)
|
||||
subject.saveBaseUrl(TEST_BASE_URL)
|
||||
advanceUntilIdle()
|
||||
assertThat(subject.currentScreenState).isEqualTo(BaseURLScreenState(null, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
|
||||
coEvery {
|
||||
|
||||
Reference in New Issue
Block a user