Replace AccountManager with EncryptedSharedPreferences
This commit is contained in:
@@ -154,6 +154,9 @@ dependencies {
|
|||||||
// https://developer.android.com/topic/libraries/architecture/datastore
|
// https://developer.android.com/topic/libraries/architecture/datastore
|
||||||
implementation "androidx.datastore:datastore-preferences:1.0.0"
|
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
|
// https://github.com/junit-team/junit4/releases
|
||||||
testImplementation "junit:junit:4.13.2"
|
testImplementation "junit:junit:4.13.2"
|
||||||
|
|
||||||
|
|||||||
@@ -15,27 +15,16 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher"
|
android:roundIcon="@mipmap/ic_launcher"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
tools:ignore="UnusedAttribute"
|
tools:ignore="UnusedAttribute"
|
||||||
android:theme="@style/Theme.Mealient">
|
android:theme="@style/Theme.Mealient">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
</application>
|
||||||
<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>
|
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -5,6 +5,8 @@ import android.view.Menu
|
|||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
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.CornerFamily
|
||||||
import com.google.android.material.shape.MaterialShapeDrawable
|
import com.google.android.material.shape.MaterialShapeDrawable
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
@@ -71,9 +73,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
Timber.v("onOptionsItemSelected() called with: item = $item")
|
Timber.v("onOptionsItemSelected() called with: item = $item")
|
||||||
val result = when (item.itemId) {
|
val result = when (item.itemId) {
|
||||||
R.id.logout, R.id.login -> {
|
R.id.login -> {
|
||||||
// When user clicks logout they don't want to be authorized
|
navigateToLogin()
|
||||||
authViewModel.authRequested = item.itemId == R.id.login
|
true
|
||||||
|
}
|
||||||
|
R.id.logout -> {
|
||||||
authViewModel.logout()
|
authViewModel.logout()
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -81,4 +85,9 @@ class MainActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
return result
|
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>
|
val isAuthorizedFlow: Flow<Boolean>
|
||||||
|
|
||||||
suspend fun authenticate(username: String, password: String)
|
suspend fun authenticate(email: String, password: String)
|
||||||
|
|
||||||
suspend fun getAuthHeader(): String?
|
suspend fun getAuthHeader(): String?
|
||||||
|
|
||||||
@@ -14,5 +14,5 @@ interface AuthRepo {
|
|||||||
|
|
||||||
suspend fun logout()
|
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 {
|
override suspend fun authenticate(username: String, password: String): String {
|
||||||
Timber.v("authenticate() called with: username = $username, password = $password")
|
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 response = sendRequest(authService, username, password)
|
||||||
val accessToken = parseToken(response)
|
val accessToken = parseToken(response)
|
||||||
Timber.v("authenticate() returned: $accessToken")
|
Timber.v("authenticate() returned: $accessToken")
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package gq.kirmanak.mealient.data.auth.impl
|
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.AuthRepo
|
||||||
|
import gq.kirmanak.mealient.data.auth.AuthStorage
|
||||||
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
@@ -12,71 +12,41 @@ import javax.inject.Singleton
|
|||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class AuthRepoImpl @Inject constructor(
|
class AuthRepoImpl @Inject constructor(
|
||||||
private val accountManagerInteractor: AccountManagerInteractor,
|
private val authStorage: AuthStorage,
|
||||||
|
private val authDataSource: AuthDataSource,
|
||||||
) : AuthRepo {
|
) : AuthRepo {
|
||||||
|
|
||||||
override val isAuthorizedFlow: Flow<Boolean>
|
override val isAuthorizedFlow: Flow<Boolean>
|
||||||
get() = accountManagerInteractor.accountUpdatesFlow()
|
get() = authStorage.authHeaderFlow.map { it != null }
|
||||||
.map { it.firstOrNull() }
|
|
||||||
.map { account ->
|
|
||||||
runCatchingExceptCancel { getAuthToken(account) }
|
|
||||||
.onFailure { Timber.e(it, "authHeaderObservable: can't get token") }
|
|
||||||
.getOrNull()
|
|
||||||
}.map { it != null }
|
|
||||||
|
|
||||||
override suspend fun authenticate(username: String, password: String) {
|
override suspend fun authenticate(email: String, password: String) {
|
||||||
Timber.v("authenticate() called with: username = $username, password = $password")
|
Timber.v("authenticate() called with: email = $email, password = $password")
|
||||||
val account = accountManagerInteractor.addAccount(username, password)
|
authDataSource.authenticate(email, password)
|
||||||
runCatchingExceptCancel {
|
.let { AUTH_HEADER_FORMAT.format(it) }
|
||||||
getAuthToken(account) // Try to get token to check if password is correct
|
.let { authStorage.setAuthHeader(it) }
|
||||||
}.onFailure {
|
authStorage.setEmail(email)
|
||||||
Timber.e(it, "authenticate: can't authorize")
|
authStorage.setPassword(password)
|
||||||
removeAccount(account) // Remove account with incorrect password
|
|
||||||
}.onSuccess {
|
|
||||||
Timber.d("authenticate: successfully authorized")
|
|
||||||
}.getOrThrow() // Throw error to show it to user
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getAuthHeader(): String? = runCatchingExceptCancel {
|
override suspend fun getAuthHeader(): String? = authStorage.getAuthHeader()
|
||||||
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()
|
|
||||||
|
|
||||||
private suspend fun getAuthToken(account: Account?): String? {
|
override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) {
|
||||||
return account?.let { accountManagerInteractor.getAuthToken(it) }
|
"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() {
|
override suspend fun logout() {
|
||||||
Timber.v("logout() called")
|
Timber.v("logout() called")
|
||||||
currentAccount()?.let { removeAccount(it) }
|
authStorage.setEmail(null)
|
||||||
|
authStorage.setPassword(null)
|
||||||
|
authStorage.setAuthHeader(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun removeAccount(account: Account) {
|
override suspend fun invalidateAuthHeader() {
|
||||||
Timber.v("removeAccount() called with: account = $account")
|
Timber.v("invalidateAuthHeader() called")
|
||||||
accountManagerInteractor.removeAccount(account)
|
val email = authStorage.getEmail() ?: return
|
||||||
}
|
val password = authStorage.getPassword() ?: return
|
||||||
|
runCatchingExceptCancel { authenticate(email, password) }
|
||||||
override fun invalidateAuthHeader(header: String) {
|
.onFailure { logout() } // Clear all known values to avoid reusing them
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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 currentHeader = authHeader ?: return chain.proceed(chain.request())
|
||||||
val response = proceedWithAuthHeader(chain, currentHeader)
|
val response = proceedWithAuthHeader(chain, currentHeader)
|
||||||
if (listOf(401, 403).contains(response.code)) {
|
if (listOf(401, 403).contains(response.code)) {
|
||||||
authRepo.invalidateAuthHeader(currentHeader)
|
runBlocking { authRepo.invalidateAuthHeader() }
|
||||||
} else {
|
} else {
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
package gq.kirmanak.mealient.data.network
|
package gq.kirmanak.mealient.data.network
|
||||||
|
|
||||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
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.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Named
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class RetrofitBuilder(
|
||||||
class RetrofitBuilder @Inject constructor(
|
private val okHttpClient: OkHttpClient,
|
||||||
@Named(AUTH_OK_HTTP) private val authOkHttpClient: OkHttpClient,
|
|
||||||
@Named(NO_AUTH_OK_HTTP) private val noAuthOkHttpClient: OkHttpClient,
|
|
||||||
private val json: Json
|
private val json: Json
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
fun buildRetrofit(baseUrl: String, needAuth: Boolean): Retrofit {
|
fun buildRetrofit(baseUrl: String): Retrofit {
|
||||||
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
|
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
|
||||||
val contentType = "application/json".toMediaType()
|
val contentType = "application/json".toMediaType()
|
||||||
val converterFactory = json.asConverterFactory(contentType)
|
val converterFactory = json.asConverterFactory(contentType)
|
||||||
val client = if (needAuth) authOkHttpClient else noAuthOkHttpClient
|
|
||||||
return Retrofit.Builder()
|
return Retrofit.Builder()
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(baseUrl)
|
||||||
.client(client)
|
.client(okHttpClient)
|
||||||
.addConverterFactory(converterFactory)
|
.addConverterFactory(converterFactory)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,28 +13,21 @@ class RetrofitServiceFactory<T>(
|
|||||||
private val baseURLStorage: BaseURLStorage,
|
private val baseURLStorage: BaseURLStorage,
|
||||||
) : ServiceFactory<T> {
|
) : ServiceFactory<T> {
|
||||||
|
|
||||||
private val cache: MutableMap<ServiceParams, T> = mutableMapOf()
|
private val cache: MutableMap<String, T> = mutableMapOf()
|
||||||
|
|
||||||
override suspend fun provideService(
|
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
|
||||||
baseUrl: String?,
|
|
||||||
needAuth: Boolean,
|
|
||||||
): T = runCatchingExceptCancel {
|
|
||||||
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
|
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
|
||||||
val url = baseUrl ?: baseURLStorage.requireBaseURL()
|
val url = baseUrl ?: baseURLStorage.requireBaseURL()
|
||||||
val params = ServiceParams(url, needAuth)
|
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
|
||||||
synchronized(cache) { cache[params] ?: createService(params, serviceClass) }
|
|
||||||
}.getOrElse {
|
}.getOrElse {
|
||||||
Timber.e(it, "provideService: can't provide service for $baseUrl")
|
Timber.e(it, "provideService: can't provide service for $baseUrl")
|
||||||
throw NetworkError.MalformedUrl(it)
|
throw NetworkError.MalformedUrl(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createService(serviceParams: ServiceParams, serviceClass: Class<T>): T {
|
private fun createService(url: String, serviceClass: Class<T>): T {
|
||||||
Timber.v("createService() called with: serviceParams = $serviceParams, serviceClass = ${serviceClass.simpleName}")
|
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
|
||||||
val (url, needAuth) = serviceParams
|
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
|
||||||
val service = retrofitBuilder.buildRetrofit(url, needAuth).create(serviceClass)
|
cache[url] = service
|
||||||
cache[serviceParams] = service
|
|
||||||
return 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> {
|
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.accounts.AccountManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKeys
|
||||||
import dagger.Binds
|
import dagger.Binds
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import gq.kirmanak.mealient.R
|
|
||||||
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
import gq.kirmanak.mealient.data.auth.AuthDataSource
|
||||||
import gq.kirmanak.mealient.data.auth.AuthRepo
|
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.AuthDataSourceImpl
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl
|
||||||
import gq.kirmanak.mealient.data.auth.impl.AuthService
|
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.baseurl.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.data.network.createServiceFactory
|
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractor
|
import kotlinx.serialization.json.Json
|
||||||
import gq.kirmanak.mealient.service.auth.AccountManagerInteractorImpl
|
import okhttp3.OkHttpClient
|
||||||
import gq.kirmanak.mealient.service.auth.AccountParameters
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -28,13 +32,17 @@ import javax.inject.Singleton
|
|||||||
interface AuthModule {
|
interface AuthModule {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val ENCRYPTED = "encrypted"
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAuthServiceFactory(
|
fun provideAuthServiceFactory(
|
||||||
retrofitBuilder: RetrofitBuilder,
|
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||||
|
json: Json,
|
||||||
baseURLStorage: BaseURLStorage,
|
baseURLStorage: BaseURLStorage,
|
||||||
): ServiceFactory<AuthService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
): ServiceFactory<AuthService> {
|
||||||
|
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -44,10 +52,20 @@ interface AuthModule {
|
|||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideAccountType(@ApplicationContext context: Context) = AccountParameters(
|
@Named(ENCRYPTED)
|
||||||
accountType = context.getString(R.string.account_type),
|
fun provideEncryptedSharedPreferences(
|
||||||
authTokenType = context.getString(R.string.auth_token_type),
|
@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
|
@Binds
|
||||||
@@ -60,7 +78,5 @@ interface AuthModule {
|
|||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
fun bindAccountManagerInteractor(
|
fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage
|
||||||
accountManagerInteractorImpl: AccountManagerInteractorImpl
|
|
||||||
): AccountManagerInteractor
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import gq.kirmanak.mealient.data.baseurl.*
|
|||||||
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
import gq.kirmanak.mealient.data.network.RetrofitBuilder
|
||||||
import gq.kirmanak.mealient.data.network.ServiceFactory
|
import gq.kirmanak.mealient.data.network.ServiceFactory
|
||||||
import gq.kirmanak.mealient.data.network.createServiceFactory
|
import gq.kirmanak.mealient.data.network.createServiceFactory
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -20,9 +23,12 @@ interface BaseURLModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideVersionServiceFactory(
|
fun provideVersionServiceFactory(
|
||||||
retrofitBuilder: RetrofitBuilder,
|
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||||
|
json: Json,
|
||||||
baseURLStorage: BaseURLStorage,
|
baseURLStorage: BaseURLStorage,
|
||||||
): ServiceFactory<VersionService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
): ServiceFactory<VersionService> {
|
||||||
|
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Binds
|
@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.RecipeDataSource
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
|
||||||
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
import gq.kirmanak.mealient.data.recipes.network.RecipeService
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import javax.inject.Named
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@@ -46,9 +49,12 @@ interface RecipeModule {
|
|||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideRecipeServiceFactory(
|
fun provideRecipeServiceFactory(
|
||||||
retrofitBuilder: RetrofitBuilder,
|
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
|
||||||
|
json: Json,
|
||||||
baseURLStorage: BaseURLStorage,
|
baseURLStorage: BaseURLStorage,
|
||||||
): ServiceFactory<RecipeService> = retrofitBuilder.createServiceFactory(baseURLStorage)
|
): ServiceFactory<RecipeService> {
|
||||||
|
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.activity.OnBackPressedDispatcher
|
|||||||
import androidx.activity.addCallback
|
import androidx.activity.addCallback
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -33,4 +34,8 @@ fun OnBackPressedDispatcher.backPressedFlow(): Flow<Unit> = callbackFlow {
|
|||||||
inline fun <T> Fragment.collectWithViewLifecycle(
|
inline fun <T> Fragment.collectWithViewLifecycle(
|
||||||
flow: Flow<T>,
|
flow: Flow<T>,
|
||||||
crossinline collector: suspend (T) -> Unit,
|
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
|
package gq.kirmanak.mealient.extensions
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.SharedPreferences
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.WindowInsets
|
import android.view.WindowInsets
|
||||||
@@ -106,4 +107,17 @@ fun EditText.checkIfInputIsEmpty(
|
|||||||
suspend fun EditText.waitUntilNotEmpty() {
|
suspend fun EditText.waitUntilNotEmpty() {
|
||||||
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
|
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
|
||||||
Timber.v("waitUntilNotEmpty() returned")
|
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.appcompat.app.AppCompatActivity
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
|
||||||
import androidx.navigation.fragment.findNavController
|
import androidx.navigation.fragment.findNavController
|
||||||
import by.kirich1409.viewbindingdelegate.viewBinding
|
import by.kirich1409.viewbindingdelegate.viewBinding
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
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.data.network.NetworkError
|
||||||
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
|
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
|
||||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||||
import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed
|
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -22,12 +20,6 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
|
|||||||
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
|
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
|
||||||
private val viewModel by activityViewModels<AuthenticationViewModel>()
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
@@ -53,9 +45,7 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
|
|||||||
) ?: return
|
) ?: return
|
||||||
|
|
||||||
button.isClickable = false
|
button.isClickable = false
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
launchWithViewLifecycle { onAuthenticationResult(viewModel.authenticate(email, pass)) }
|
||||||
onAuthenticationResult(viewModel.authenticate(email, pass))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onAuthenticationResult(result: Result<Unit>) {
|
private fun onAuthenticationResult(result: Result<Unit>) {
|
||||||
|
|||||||
@@ -4,22 +4,19 @@ import timber.log.Timber
|
|||||||
|
|
||||||
enum class AuthenticationState {
|
enum class AuthenticationState {
|
||||||
AUTHORIZED,
|
AUTHORIZED,
|
||||||
AUTH_REQUESTED,
|
|
||||||
UNAUTHORIZED,
|
UNAUTHORIZED,
|
||||||
UNKNOWN;
|
UNKNOWN;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
fun determineState(
|
fun determineState(
|
||||||
isLoginRequested: Boolean,
|
|
||||||
showLoginButton: Boolean,
|
showLoginButton: Boolean,
|
||||||
isAuthorized: Boolean,
|
isAuthorized: Boolean,
|
||||||
): AuthenticationState {
|
): AuthenticationState {
|
||||||
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
|
Timber.v("determineState() called with: showLoginButton = $showLoginButton, isAuthorized = $isAuthorized")
|
||||||
val result = when {
|
val result = when {
|
||||||
!showLoginButton -> UNKNOWN
|
!showLoginButton -> UNKNOWN
|
||||||
isAuthorized -> AUTHORIZED
|
isAuthorized -> AUTHORIZED
|
||||||
isLoginRequested -> AUTH_REQUESTED
|
|
||||||
else -> UNAUTHORIZED
|
else -> UNAUTHORIZED
|
||||||
}
|
}
|
||||||
Timber.v("determineState() returned: $result")
|
Timber.v("determineState() returned: $result")
|
||||||
|
|||||||
@@ -18,21 +18,18 @@ class AuthenticationViewModel @Inject constructor(
|
|||||||
private val authRepo: AuthRepo,
|
private val authRepo: AuthRepo,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val authRequestsFlow = MutableStateFlow(false)
|
|
||||||
private val showLoginButtonFlow = MutableStateFlow(false)
|
private val showLoginButtonFlow = MutableStateFlow(false)
|
||||||
private val authenticationStateFlow = combine(
|
private val authenticationStateFlow = combine(
|
||||||
authRequestsFlow,
|
|
||||||
showLoginButtonFlow,
|
showLoginButtonFlow,
|
||||||
authRepo.isAuthorizedFlow,
|
authRepo.isAuthorizedFlow,
|
||||||
AuthenticationState::determineState
|
AuthenticationState::determineState
|
||||||
)
|
)
|
||||||
val authenticationStateLive: LiveData<AuthenticationState>
|
val authenticationStateLive: LiveData<AuthenticationState>
|
||||||
get() = authenticationStateFlow.asLiveData()
|
get() = authenticationStateFlow.asLiveData()
|
||||||
var authRequested: Boolean by authRequestsFlow::value
|
|
||||||
var showLoginButton: Boolean by showLoginButtonFlow::value
|
var showLoginButton: Boolean by showLoginButtonFlow::value
|
||||||
|
|
||||||
suspend fun authenticate(username: String, password: String) = runCatchingExceptCancel {
|
suspend fun authenticate(email: String, password: String) = runCatchingExceptCancel {
|
||||||
authRepo.authenticate(username, password)
|
authRepo.authenticate(email, password)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
Timber.e(it, "authenticate: can't authenticate")
|
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.data.network.NetworkError
|
||||||
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
|
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
|
||||||
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
|
||||||
|
import gq.kirmanak.mealient.extensions.launchWithViewLifecycle
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -22,7 +23,6 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
|
|
||||||
binding.button.setOnClickListener(::onProceedClick)
|
binding.button.setOnClickListener(::onProceedClick)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,16 +33,16 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
|
|||||||
lifecycleOwner = viewLifecycleOwner,
|
lifecycleOwner = viewLifecycleOwner,
|
||||||
stringId = R.string.fragment_baseurl_url_input_empty,
|
stringId = R.string.fragment_baseurl_url_input_empty,
|
||||||
) ?: return
|
) ?: return
|
||||||
viewModel.saveBaseUrl(url)
|
launchWithViewLifecycle { onCheckURLResult(viewModel.saveBaseUrl(url)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateState(baseURLScreenState: BaseURLScreenState) {
|
private fun onCheckURLResult(result: Result<Unit>) {
|
||||||
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
|
Timber.v("onCheckURLResult() called with: result = $result")
|
||||||
if (baseURLScreenState.navigateNext) {
|
if (result.isSuccess) {
|
||||||
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
|
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
|
||||||
return
|
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.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
|
||||||
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
|
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
|
||||||
is NetworkError.MalformedUrl -> {
|
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
|
package gq.kirmanak.mealient.ui.baseurl
|
||||||
|
|
||||||
import androidx.lifecycle.LiveData
|
|
||||||
import androidx.lifecycle.MutableLiveData
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
||||||
import gq.kirmanak.mealient.data.network.NetworkError
|
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -18,35 +14,21 @@ class BaseURLViewModel @Inject constructor(
|
|||||||
private val versionDataSource: VersionDataSource,
|
private val versionDataSource: VersionDataSource,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _screenState = MutableLiveData(BaseURLScreenState())
|
suspend fun saveBaseUrl(baseURL: String): Result<Unit> {
|
||||||
var currentScreenState: BaseURLScreenState
|
|
||||||
get() = _screenState.value!!
|
|
||||||
private set(value) {
|
|
||||||
_screenState.value = value
|
|
||||||
}
|
|
||||||
val screenState: LiveData<BaseURLScreenState>
|
|
||||||
get() = _screenState
|
|
||||||
|
|
||||||
fun saveBaseUrl(baseURL: String) {
|
|
||||||
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
|
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
|
||||||
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
|
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
|
||||||
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL)
|
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")
|
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
|
// If it returns proper version info then it must be a Mealie
|
||||||
versionDataSource.getVersionInfo(baseURL)
|
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)
|
baseURLStorage.storeBaseURL(baseURL)
|
||||||
currentScreenState = BaseURLScreenState(null, true)
|
return result.map { }
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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.databinding.FragmentRecipesBinding
|
||||||
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
|
import gq.kirmanak.mealient.extensions.collectWithViewLifecycle
|
||||||
import gq.kirmanak.mealient.extensions.refreshRequestFlow
|
import gq.kirmanak.mealient.extensions.refreshRequestFlow
|
||||||
import gq.kirmanak.mealient.ui.auth.AuthenticationState
|
|
||||||
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
|
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
@@ -24,19 +23,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
|
|||||||
private val viewModel by viewModels<RecipeViewModel>()
|
private val viewModel by viewModels<RecipeViewModel>()
|
||||||
private val authViewModel by activityViewModels<AuthenticationViewModel>()
|
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?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
|
||||||
|
|||||||
@@ -9,15 +9,16 @@
|
|||||||
android:id="@+id/authenticationFragment"
|
android:id="@+id/authenticationFragment"
|
||||||
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
|
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
|
||||||
android:label="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
|
<fragment
|
||||||
android:id="@+id/recipesFragment"
|
android:id="@+id/recipesFragment"
|
||||||
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
|
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
|
||||||
android:label="fragment_recipes"
|
android:label="fragment_recipes"
|
||||||
tools:layout="@layout/fragment_recipes">
|
tools:layout="@layout/fragment_recipes">
|
||||||
<action
|
|
||||||
android:id="@+id/action_recipesFragment_to_authenticationFragment"
|
|
||||||
app:destination="@id/authenticationFragment" />
|
|
||||||
<action
|
<action
|
||||||
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
|
||||||
app:destination="@id/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() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
|
subject = AuthDataSourceImpl(authServiceFactory, NetworkModule.createJson())
|
||||||
coEvery { authServiceFactory.provideService(any(), eq(false)) } returns authService
|
coEvery { authServiceFactory.provideService(any()) } returns authService
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -72,7 +72,7 @@ class AuthDataSourceImplTest {
|
|||||||
@Test(expected = MalformedUrl::class)
|
@Test(expected = MalformedUrl::class)
|
||||||
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
|
fun `when authenticate and provideService throws then MalformedUrl`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
authServiceFactory.provideService(any(), eq(false))
|
authServiceFactory.provideService(any())
|
||||||
} throws MalformedUrl(RuntimeException())
|
} throws MalformedUrl(RuntimeException())
|
||||||
callAuthenticate()
|
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 {
|
coVerifySequence {
|
||||||
authRepo.getAuthHeader()
|
authRepo.getAuthHeader()
|
||||||
authRepo.invalidateAuthHeader(TEST_AUTH_HEADER)
|
authRepo.invalidateAuthHeader()
|
||||||
authRepo.getAuthHeader()
|
authRepo.getAuthHeader()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class RetrofitServiceFactoryTest {
|
|||||||
fun setUp() {
|
fun setUp() {
|
||||||
MockKAnnotations.init(this)
|
MockKAnnotations.init(this)
|
||||||
subject = retrofitBuilder.createServiceFactory(baseURLStorage)
|
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
|
every { retrofit.create(eq(VersionService::class.java)) } returns versionService
|
||||||
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
|
coEvery { baseURLStorage.requireBaseURL() } returns TEST_BASE_URL
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ class RetrofitServiceFactoryTest {
|
|||||||
fun `when provideService called twice then builder called once`() = runTest {
|
fun `when provideService called twice then builder called once`() = runTest {
|
||||||
subject.provideService()
|
subject.provideService()
|
||||||
subject.provideService()
|
subject.provideService()
|
||||||
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true)) }
|
coVerifyAll { retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -61,8 +61,8 @@ class RetrofitServiceFactoryTest {
|
|||||||
subject.provideService()
|
subject.provideService()
|
||||||
subject.provideService("new url")
|
subject.provideService("new url")
|
||||||
coVerifyAll {
|
coVerifyAll {
|
||||||
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL), eq(true))
|
retrofitBuilder.buildRetrofit(eq(TEST_BASE_URL))
|
||||||
retrofitBuilder.buildRetrofit(eq("new url"), eq(true))
|
retrofitBuilder.buildRetrofit(eq("new url"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
package gq.kirmanak.mealient.ui.baseurl
|
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.BaseURLStorage
|
||||||
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
|
||||||
import gq.kirmanak.mealient.data.baseurl.VersionInfo
|
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.AuthImplTestData.TEST_BASE_URL
|
||||||
import gq.kirmanak.mealient.test.RobolectricTest
|
import gq.kirmanak.mealient.test.RobolectricTest
|
||||||
import io.mockk.MockKAnnotations
|
import io.mockk.MockKAnnotations
|
||||||
@@ -34,35 +32,6 @@ class BaseURLViewModelTest : RobolectricTest() {
|
|||||||
subject = BaseURLViewModel(baseURLStorage, versionDataSource)
|
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
|
@Test
|
||||||
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
|
fun `when saveBaseUrl and getVersionInfo returns result then saves to storage`() = runTest {
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|||||||
Reference in New Issue
Block a user