Implement observing authentication statuses

This commit is contained in:
Kirill Kamakin
2021-11-14 10:27:45 +03:00
parent 1a136b6ade
commit 698d93b351
9 changed files with 84 additions and 30 deletions

View File

@@ -7,8 +7,10 @@ import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealie.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealie.data.auth.impl.AuthDataSourceImpl
import gq.kirmanak.mealie.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealie.data.auth.impl.AuthRepoImpl
import gq.kirmanak.mealie.data.auth.impl.AuthStorageImpl import gq.kirmanak.mealie.data.auth.impl.AuthStorageImpl
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
@ExperimentalCoroutinesApi
@ExperimentalSerializationApi @ExperimentalSerializationApi
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View File

@@ -1,11 +1,13 @@
package gq.kirmanak.mealie.data.auth package gq.kirmanak.mealie.data.auth
interface AuthRepo { import kotlinx.coroutines.flow.Flow
suspend fun isAuthenticated(): Boolean
interface AuthRepo {
suspend fun authenticate(username: String, password: String, baseUrl: String) suspend fun authenticate(username: String, password: String, baseUrl: String)
suspend fun getBaseUrl(): String? suspend fun getBaseUrl(): String?
suspend fun getToken(): String? suspend fun getToken(): String?
fun authenticationStatuses(): Flow<Boolean>
} }

View File

@@ -1,9 +1,13 @@
package gq.kirmanak.mealie.data.auth package gq.kirmanak.mealie.data.auth
import kotlinx.coroutines.flow.Flow
interface AuthStorage { interface AuthStorage {
suspend fun storeAuthData(token: String, baseUrl: String) suspend fun storeAuthData(token: String, baseUrl: String)
suspend fun getBaseUrl(): String? suspend fun getBaseUrl(): String?
suspend fun getToken(): String? suspend fun getToken(): String?
fun tokenObservable(): Flow<String?>
} }

View File

@@ -3,6 +3,8 @@ package gq.kirmanak.mealie.data.auth.impl
import gq.kirmanak.mealie.data.auth.AuthDataSource import gq.kirmanak.mealie.data.auth.AuthDataSource
import gq.kirmanak.mealie.data.auth.AuthRepo import gq.kirmanak.mealie.data.auth.AuthRepo
import gq.kirmanak.mealie.data.auth.AuthStorage import gq.kirmanak.mealie.data.auth.AuthStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -10,13 +12,6 @@ class AuthRepoImpl @Inject constructor(
private val dataSource: AuthDataSource, private val dataSource: AuthDataSource,
private val storage: AuthStorage private val storage: AuthStorage
) : AuthRepo { ) : AuthRepo {
override suspend fun isAuthenticated(): Boolean {
Timber.v("isAuthenticated")
val authenticated = getToken() != null
Timber.d("isAuthenticated() response $authenticated")
return authenticated
}
override suspend fun authenticate( override suspend fun authenticate(
username: String, username: String,
password: String, password: String,
@@ -38,4 +33,9 @@ class AuthRepoImpl @Inject constructor(
Timber.v("getToken() called") Timber.v("getToken() called")
return storage.getToken() return storage.getToken()
} }
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.tokenObservable().map { it != null }
}
} }

View File

@@ -6,6 +6,12 @@ import androidx.preference.PreferenceManager
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import gq.kirmanak.mealie.data.auth.AuthStorage import gq.kirmanak.mealie.data.auth.AuthStorage
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -13,6 +19,7 @@ import javax.inject.Inject
private const val TOKEN_KEY = "AUTH_TOKEN" private const val TOKEN_KEY = "AUTH_TOKEN"
private const val BASE_URL_KEY = "BASE_URL" private const val BASE_URL_KEY = "BASE_URL"
@ExperimentalCoroutinesApi
class AuthStorageImpl @Inject constructor(@ApplicationContext private val context: Context) : class AuthStorageImpl @Inject constructor(@ApplicationContext private val context: Context) :
AuthStorage { AuthStorage {
private val sharedPreferences: SharedPreferences private val sharedPreferences: SharedPreferences
@@ -40,6 +47,29 @@ class AuthStorageImpl @Inject constructor(@ApplicationContext private val contex
return token return token
} }
override fun tokenObservable(): Flow<String?> {
Timber.v("tokenObservable() called")
return callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key ->
Timber.v("tokenObservable: listener called with key $key")
val token = when (key) {
null -> null
TOKEN_KEY -> prefs.getString(key, null)
else -> return@OnSharedPreferenceChangeListener
}
Timber.d("tokenObservable: New token: $token")
trySendBlocking(token).onFailure { Timber.e(it, "Can't send new token") }
}
Timber.v("tokenObservable: registering listener")
send(getToken())
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
awaitClose {
Timber.v("tokenObservable: flow has been closed")
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
}
}
private suspend fun getString(key: String): String? = withContext(Dispatchers.Default) { private suspend fun getString(key: String): String? = withContext(Dispatchers.Default) {
sharedPreferences.getString(key, null) sharedPreferences.getString(key, null)
} }

View File

@@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding
import kotlinx.coroutines.flow.collectLatest
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@@ -24,7 +25,17 @@ class AuthenticationFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
checkIfAuthenticatedAlready() listenToAuthenticationStatuses()
}
private fun listenToAuthenticationStatuses() {
Timber.d("listenToAuthenticationStatuses() called")
lifecycleScope.launchWhenCreated {
viewModel.authenticationStatuses().collectLatest {
Timber.d("listenToAuthenticationStatuses: new status = $it")
if (it) navigateToRecipes()
}
}
} }
override fun onCreateView( override fun onCreateView(
@@ -43,13 +54,6 @@ class AuthenticationFragment : Fragment() {
binding.button.setOnClickListener { onLoginClicked() } binding.button.setOnClickListener { onLoginClicked() }
} }
private fun checkIfAuthenticatedAlready() {
Timber.v("checkIfAuthenticatedAlready() called")
lifecycleScope.launchWhenCreated {
if (viewModel.isAuthenticated()) navigateToRecipes()
}
}
private fun navigateToRecipes() { private fun navigateToRecipes() {
findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment()) findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment())
} }
@@ -73,8 +77,6 @@ class AuthenticationFragment : Fragment() {
lifecycleScope.launchWhenResumed { lifecycleScope.launchWhenResumed {
runCatching { runCatching {
viewModel.authenticate(email, pass, url) viewModel.authenticate(email, pass, url)
}.onSuccess {
navigateToRecipes()
}.onFailure { }.onFailure {
Timber.e(it, "Can't authenticate") Timber.e(it, "Can't authenticate")
} }

View File

@@ -3,6 +3,7 @@ package gq.kirmanak.mealie.ui.auth
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealie.data.auth.AuthRepo import gq.kirmanak.mealie.data.auth.AuthRepo
import kotlinx.coroutines.flow.Flow
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -14,15 +15,13 @@ class AuthenticationViewModel @Inject constructor(
Timber.v("constructor called") Timber.v("constructor called")
} }
suspend fun isAuthenticated(): Boolean {
Timber.v("isAuthenticated() called")
val result = authRepo.isAuthenticated()
Timber.d("isAuthenticated() returned: $result")
return result
}
suspend fun authenticate(username: String, password: String, baseUrl: String) { suspend fun authenticate(username: String, password: String, baseUrl: String) {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
authRepo.authenticate(username, password, baseUrl) authRepo.authenticate(username, password, baseUrl)
} }
fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return authRepo.authenticationStatuses()
}
} }

View File

@@ -7,6 +7,7 @@ import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_USERNAME import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_USERNAME
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueSuccessfulAuthResponse import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueSuccessfulAuthResponse
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueUnsuccessfulAuthResponse import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueUnsuccessfulAuthResponse
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
import retrofit2.HttpException import retrofit2.HttpException
@@ -18,15 +19,15 @@ class AuthRepoImplTest : MockServerTest() {
lateinit var subject: AuthRepoImpl lateinit var subject: AuthRepoImpl
@Test @Test
fun `when not authenticated then isAuthenticated false`() = runBlocking { fun `when not authenticated then first auth status is false`() = runBlocking {
assertThat(subject.isAuthenticated()).isFalse() assertThat(subject.authenticationStatuses().first()).isFalse()
} }
@Test @Test
fun `when authenticated then isAuthenticated true`() = runBlocking { fun `when authenticated then first auth status is true`() = runBlocking {
mockServer.enqueueSuccessfulAuthResponse() mockServer.enqueueSuccessfulAuthResponse()
subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl)
assertThat(subject.isAuthenticated()).isTrue() assertThat(subject.authenticationStatuses().first()).isTrue()
} }
@Test(expected = HttpException::class) @Test(expected = HttpException::class)

View File

@@ -4,10 +4,13 @@ import com.google.common.truth.Truth.assertThat
import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltAndroidTest
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_TOKEN import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_TOKEN
import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_URL import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.TEST_URL
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Test import org.junit.Test
import javax.inject.Inject import javax.inject.Inject
@ExperimentalCoroutinesApi
@HiltAndroidTest @HiltAndroidTest
class AuthStorageImplTest : HiltRobolectricTest() { class AuthStorageImplTest : HiltRobolectricTest() {
@Inject @Inject
@@ -39,4 +42,15 @@ class AuthStorageImplTest : HiltRobolectricTest() {
fun `when reading url without storing data then returns null`() = runBlocking { fun `when reading url without storing data then returns null`() = runBlocking {
assertThat(subject.getBaseUrl()).isNull() assertThat(subject.getBaseUrl()).isNull()
} }
@Test
fun `when didn't store auth data then first token is null`() = runBlocking {
assertThat(subject.tokenObservable().first()).isNull()
}
@Test
fun `when stored auth data then first token is correct`() = runBlocking {
subject.storeAuthData(TEST_TOKEN, TEST_URL)
assertThat(subject.tokenObservable().first()).isEqualTo(TEST_TOKEN)
}
} }