From 698d93b351be979d09b97400fe66fd27230d3bdc Mon Sep 17 00:00:00 2001 From: Kirill Kamakin Date: Sun, 14 Nov 2021 10:27:45 +0300 Subject: [PATCH] Implement observing authentication statuses --- .../kirmanak/mealie/data/auth/AuthModule.kt | 2 ++ .../gq/kirmanak/mealie/data/auth/AuthRepo.kt | 6 ++-- .../kirmanak/mealie/data/auth/AuthStorage.kt | 4 +++ .../mealie/data/auth/impl/AuthRepoImpl.kt | 14 ++++----- .../mealie/data/auth/impl/AuthStorageImpl.kt | 30 +++++++++++++++++++ .../mealie/ui/auth/AuthenticationFragment.kt | 22 +++++++------- .../mealie/ui/auth/AuthenticationViewModel.kt | 13 ++++---- .../mealie/data/auth/impl/AuthRepoImplTest.kt | 9 +++--- .../data/auth/impl/AuthStorageImplTest.kt | 14 +++++++++ 9 files changed, 84 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt index 945a7c6..6825c79 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthModule.kt @@ -7,8 +7,10 @@ import dagger.hilt.components.SingletonComponent import gq.kirmanak.mealie.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealie.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealie.data.auth.impl.AuthStorageImpl +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.serialization.ExperimentalSerializationApi +@ExperimentalCoroutinesApi @ExperimentalSerializationApi @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthRepo.kt index 253f5f7..e1d547a 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthRepo.kt @@ -1,11 +1,13 @@ package gq.kirmanak.mealie.data.auth -interface AuthRepo { - suspend fun isAuthenticated(): Boolean +import kotlinx.coroutines.flow.Flow +interface AuthRepo { suspend fun authenticate(username: String, password: String, baseUrl: String) suspend fun getBaseUrl(): String? suspend fun getToken(): String? + + fun authenticationStatuses(): Flow } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthStorage.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthStorage.kt index a7724ed..79e1cef 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/AuthStorage.kt @@ -1,9 +1,13 @@ package gq.kirmanak.mealie.data.auth +import kotlinx.coroutines.flow.Flow + interface AuthStorage { suspend fun storeAuthData(token: String, baseUrl: String) suspend fun getBaseUrl(): String? suspend fun getToken(): String? + + fun tokenObservable(): Flow } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImpl.kt index 40da834..0b8c96d 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImpl.kt @@ -3,6 +3,8 @@ package gq.kirmanak.mealie.data.auth.impl import gq.kirmanak.mealie.data.auth.AuthDataSource import gq.kirmanak.mealie.data.auth.AuthRepo import gq.kirmanak.mealie.data.auth.AuthStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject @@ -10,13 +12,6 @@ class AuthRepoImpl @Inject constructor( private val dataSource: AuthDataSource, private val storage: AuthStorage ) : AuthRepo { - override suspend fun isAuthenticated(): Boolean { - Timber.v("isAuthenticated") - val authenticated = getToken() != null - Timber.d("isAuthenticated() response $authenticated") - return authenticated - } - override suspend fun authenticate( username: String, password: String, @@ -38,4 +33,9 @@ class AuthRepoImpl @Inject constructor( Timber.v("getToken() called") return storage.getToken() } + + override fun authenticationStatuses(): Flow { + Timber.v("authenticationStatuses() called") + return storage.tokenObservable().map { it != null } + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImpl.kt index 786b53f..44c2c74 100644 --- a/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImpl.kt @@ -6,6 +6,12 @@ import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext import gq.kirmanak.mealie.data.auth.AuthStorage 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 timber.log.Timber import javax.inject.Inject @@ -13,6 +19,7 @@ import javax.inject.Inject private const val TOKEN_KEY = "AUTH_TOKEN" private const val BASE_URL_KEY = "BASE_URL" +@ExperimentalCoroutinesApi class AuthStorageImpl @Inject constructor(@ApplicationContext private val context: Context) : AuthStorage { private val sharedPreferences: SharedPreferences @@ -40,6 +47,29 @@ class AuthStorageImpl @Inject constructor(@ApplicationContext private val contex return token } + override fun tokenObservable(): Flow { + 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) { sharedPreferences.getString(key, null) } diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt index 338101e..42ff44f 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationFragment.kt @@ -12,6 +12,7 @@ import androidx.navigation.fragment.findNavController import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealie.databinding.FragmentAuthenticationBinding +import kotlinx.coroutines.flow.collectLatest import timber.log.Timber @AndroidEntryPoint @@ -24,7 +25,17 @@ class AuthenticationFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(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( @@ -43,13 +54,6 @@ class AuthenticationFragment : Fragment() { binding.button.setOnClickListener { onLoginClicked() } } - private fun checkIfAuthenticatedAlready() { - Timber.v("checkIfAuthenticatedAlready() called") - lifecycleScope.launchWhenCreated { - if (viewModel.isAuthenticated()) navigateToRecipes() - } - } - private fun navigateToRecipes() { findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment()) } @@ -73,8 +77,6 @@ class AuthenticationFragment : Fragment() { lifecycleScope.launchWhenResumed { runCatching { viewModel.authenticate(email, pass, url) - }.onSuccess { - navigateToRecipes() }.onFailure { Timber.e(it, "Can't authenticate") } diff --git a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt index 2f8c1d3..ed598ba 100644 --- a/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealie/ui/auth/AuthenticationViewModel.kt @@ -3,6 +3,7 @@ package gq.kirmanak.mealie.ui.auth import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealie.data.auth.AuthRepo +import kotlinx.coroutines.flow.Flow import timber.log.Timber import javax.inject.Inject @@ -14,15 +15,13 @@ class AuthenticationViewModel @Inject constructor( 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) { Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") authRepo.authenticate(username, password, baseUrl) } + + fun authenticationStatuses(): Flow { + Timber.v("authenticationStatuses() called") + return authRepo.authenticationStatuses() + } } \ No newline at end of file diff --git a/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImplTest.kt b/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImplTest.kt index feabaf6..1b7b360 100644 --- a/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthRepoImplTest.kt @@ -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.enqueueSuccessfulAuthResponse import gq.kirmanak.mealie.data.auth.impl.AuthImplTestData.enqueueUnsuccessfulAuthResponse +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Test import retrofit2.HttpException @@ -18,15 +19,15 @@ class AuthRepoImplTest : MockServerTest() { lateinit var subject: AuthRepoImpl @Test - fun `when not authenticated then isAuthenticated false`() = runBlocking { - assertThat(subject.isAuthenticated()).isFalse() + fun `when not authenticated then first auth status is false`() = runBlocking { + assertThat(subject.authenticationStatuses().first()).isFalse() } @Test - fun `when authenticated then isAuthenticated true`() = runBlocking { + fun `when authenticated then first auth status is true`() = runBlocking { mockServer.enqueueSuccessfulAuthResponse() subject.authenticate(TEST_USERNAME, TEST_PASSWORD, serverUrl) - assertThat(subject.isAuthenticated()).isTrue() + assertThat(subject.authenticationStatuses().first()).isTrue() } @Test(expected = HttpException::class) diff --git a/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImplTest.kt b/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImplTest.kt index 92ed144..ebd8150 100644 --- a/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImplTest.kt +++ b/app/src/test/java/gq/kirmanak/mealie/data/auth/impl/AuthStorageImplTest.kt @@ -4,10 +4,13 @@ import com.google.common.truth.Truth.assertThat 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_URL +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Test import javax.inject.Inject +@ExperimentalCoroutinesApi @HiltAndroidTest class AuthStorageImplTest : HiltRobolectricTest() { @Inject @@ -39,4 +42,15 @@ class AuthStorageImplTest : HiltRobolectricTest() { fun `when reading url without storing data then returns null`() = runBlocking { 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) + } } \ No newline at end of file