From 1ebf7a2e4b62d7ac4984d0ff8d6468001162a5d4 Mon Sep 17 00:00:00 2001 From: Ammar Githam Date: Wed, 23 Jun 2021 20:52:45 +0900 Subject: [PATCH] Update ProfileFragmentViewModel --- app/build.gradle | 2 + .../viewmodels/ProfileFragmentViewModel.kt | 85 +++++++++++------ .../webservices/GraphQLRepository.kt | 4 +- .../instagrabber/MainCoroutineScopeRule.kt | 84 +++++++++++++++++ .../ProfileFragmentViewModelTest.kt | 93 +++++++++++++------ 5 files changed, 214 insertions(+), 54 deletions(-) create mode 100644 app/src/test/java/awais/instagrabber/MainCoroutineScopeRule.kt diff --git a/app/build.gradle b/app/build.gradle index d1755725..e7fbcbfd 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -192,6 +192,7 @@ dependencies { // Lifecycle implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" // Room def room_version = "2.3.0" @@ -244,6 +245,7 @@ dependencies { testImplementation "androidx.test:core-ktx:1.3.0" testImplementation "androidx.arch.core:core-testing:2.1.0" testImplementation "org.robolectric:robolectric:4.5.1" + testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.0' androidTestImplementation 'org.junit.jupiter:junit-jupiter:5.7.2' androidTestImplementation 'androidx.test:core:1.3.0' diff --git a/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt index af40709e..3094f4f6 100644 --- a/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt +++ b/app/src/main/java/awais/instagrabber/viewmodels/ProfileFragmentViewModel.kt @@ -6,11 +6,12 @@ import androidx.savedstate.SavedStateRegistryOwner import awais.instagrabber.db.repositories.AccountRepository import awais.instagrabber.db.repositories.FavoriteRepository import awais.instagrabber.managers.DirectMessagesManager -import awais.instagrabber.models.enums.BroadcastItemType import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.repositories.responses.directmessages.RankedRecipient import awais.instagrabber.webservices.* +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers class ProfileFragmentViewModel( state: SavedStateHandle, @@ -21,12 +22,61 @@ class ProfileFragmentViewModel( graphQLRepository: GraphQLRepository, accountRepository: AccountRepository, favoriteRepository: FavoriteRepository, + ioDispatcher: CoroutineDispatcher, ) : ViewModel() { - private val _profile = MutableLiveData>(Resource.loading(null)) - private val _isLoggedIn = MutableLiveData(false) + private val _currentUser = MutableLiveData>(Resource.loading(null)) private var messageManager: DirectMessagesManager? = null - val profile: LiveData> = _profile + val currentUser: LiveData> = _currentUser + val isLoggedIn: LiveData = currentUser.map { it.data != null } + + private val currentUserAndStateUsernameLiveData: LiveData, Resource>> = + object : MediatorLiveData, Resource>>() { + var user: Resource = Resource.loading(null) + var stateUsername: Resource = Resource.loading(null) + + init { + addSource(currentUser) { currentUser -> + this.user = currentUser + value = currentUser to stateUsername + } + addSource(state.getLiveData("username")) { username -> + this.stateUsername = Resource.success(username) + value = user to this.stateUsername + } + // trigger currentUserAndStateUsernameLiveData switch map with a state username success resource + if (!state.contains("username")) { + this.stateUsername = Resource.success(null) + value = user to this.stateUsername + } + } + } + + val profile: LiveData> = currentUserAndStateUsernameLiveData.switchMap { + val (userResource, stateUsernameResource) = it + liveData>(context = viewModelScope.coroutineContext + ioDispatcher) { + if (userResource.status == Resource.Status.LOADING || stateUsernameResource.status == Resource.Status.LOADING) { + emit(Resource.loading(null)) + return@liveData + } + val user = userResource.data + val stateUsername = stateUsernameResource.data + if (stateUsername.isNullOrBlank()) { + emit(Resource.success(user)) + return@liveData + } + try { + val fetchedUser = if (user != null) { + userRepository.getUsernameInfo(stateUsername) // logged in + } else { + graphQLRepository.fetchUser(stateUsername) // anonymous + } + emit(Resource.success(fetchedUser)) + } catch (e: Exception) { + emit(Resource.error(e.message, null)) + } + } + } /** * Username of profile without '`@`' @@ -37,30 +87,12 @@ class ProfileFragmentViewModel( Resource.Status.SUCCESS -> it.data?.username ?: "" } } - val isLoggedIn: LiveData = _isLoggedIn - - var currentUser: Resource? = null - set(value) { - _isLoggedIn.postValue(value?.data != null) - // if no profile, and value is valid, set it as profile - val profileValue = profile.value - if ( - profileValue?.status != Resource.Status.LOADING - && profileValue?.data == null - && value?.status == Resource.Status.SUCCESS - && value.data != null - ) { - _profile.postValue(Resource.success(value.data)) - } - field = value - } - init { // Log.d(TAG, "${state.keys()} $userRepository $friendshipRepository $storiesRepository $mediaRepository") - val usernameFromState = state.get("username") - if (usernameFromState.isNullOrBlank()) { - _profile.postValue(Resource.success(null)) - } + } + + fun setCurrentUser(currentUser: Resource) { + _currentUser.postValue(currentUser) } fun shareDm(result: RankedRecipient) { @@ -104,6 +136,7 @@ class ProfileFragmentViewModelFactory( graphQLRepository, accountRepository, favoriteRepository, + Dispatchers.IO, ) as T } } diff --git a/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt b/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt index 027fc63a..c151bd31 100644 --- a/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt +++ b/app/src/main/java/awais/instagrabber/webservices/GraphQLRepository.kt @@ -12,7 +12,7 @@ import org.json.JSONObject import java.util.* -class GraphQLRepository(private val service: GraphQLService) { +open class GraphQLRepository(private val service: GraphQLService) { // TODO convert string response to a response class private suspend fun fetch( @@ -176,7 +176,7 @@ class GraphQLRepository(private val service: GraphQLService) { } // TODO convert string response to a response class - suspend fun fetchUser( + open suspend fun fetchUser( username: String, ): User { val response = service.getUser(username) diff --git a/app/src/test/java/awais/instagrabber/MainCoroutineScopeRule.kt b/app/src/test/java/awais/instagrabber/MainCoroutineScopeRule.kt new file mode 100644 index 00000000..fcd9f63c --- /dev/null +++ b/app/src/test/java/awais/instagrabber/MainCoroutineScopeRule.kt @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package awais.instagrabber + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * MainCoroutineRule installs a TestCoroutineDispatcher for Disptachers.Main. + * + * Since it extends TestCoroutineScope, you can directly launch coroutines on the MainCoroutineRule + * as a [CoroutineScope]: + * + * ``` + * mainCoroutineRule.launch { aTestCoroutine() } + * ``` + * + * All coroutines started on [MainCoroutineScopeRule] must complete (including timeouts) before the test + * finishes, or it will throw an exception. + * + * When using MainCoroutineRule you should always invoke runBlockingTest on it to avoid creating two + * instances of [TestCoroutineDispatcher] or [TestCoroutineScope] in your test: + * + * ``` + * @Test + * fun usingRunBlockingTest() = mainCoroutineRule.runBlockingTest { + * aTestCoroutine() + * } + * ``` + * + * You may call [DelayController] methods on [MainCoroutineScopeRule] and they will control the + * virtual-clock. + * + * ``` + * mainCoroutineRule.pauseDispatcher() + * // do some coroutines + * mainCoroutineRule.advanceUntilIdle() // run all pending coroutines until the dispatcher is idle + * ``` + * + * By default, [MainCoroutineScopeRule] will be in a *resumed* state. + * + * @param dispatcher if provided, this [TestCoroutineDispatcher] will be used. + */ +@ExperimentalCoroutinesApi +class MainCoroutineScopeRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : + TestWatcher(), + TestCoroutineScope by TestCoroutineScope(dispatcher) { + override fun starting(description: Description?) { + super.starting(description) + // If your codebase allows the injection of other dispatchers like + // Dispatchers.Default and Dispatchers.IO, consider injecting all of them here + // and renaming this class to `CoroutineScopeRule` + // + // All injected dispatchers in a test should point to a single instance of + // TestCoroutineDispatcher. + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + cleanupTestCoroutines() + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt b/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt index f5af75ce..63372112 100644 --- a/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt +++ b/app/src/test/java/awais/instagrabber/viewmodels/ProfileFragmentViewModelTest.kt @@ -3,6 +3,7 @@ package awais.instagrabber.viewmodels import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 +import awais.instagrabber.MainCoroutineScopeRule import awais.instagrabber.common.* import awais.instagrabber.db.datasources.AccountDataSource import awais.instagrabber.db.datasources.FavoriteDataSource @@ -12,6 +13,7 @@ import awais.instagrabber.getOrAwaitValue import awais.instagrabber.models.Resource import awais.instagrabber.repositories.responses.User import awais.instagrabber.webservices.* +import kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.Rule import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals @@ -21,12 +23,22 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) internal class ProfileFragmentViewModelTest { + private val testPublicUser = User( + pk = 100, + username = "test", + fullName = "Test user" + ) + @get:Rule var instantExecutorRule = InstantTaskExecutorRule() + @ExperimentalCoroutinesApi + @get:Rule + val coroutineScope = MainCoroutineScopeRule() + + @ExperimentalCoroutinesApi @Test fun testNoUsernameNoCurrentUser() { - val accountDataSource = AccountDataSource(AccountDaoAdapter()) val viewModel = ProfileFragmentViewModel( SavedStateHandle(), UserRepository(UserServiceAdapter()), @@ -34,46 +46,75 @@ internal class ProfileFragmentViewModelTest { StoriesRepository(StoriesServiceAdapter()), MediaRepository(MediaServiceAdapter()), GraphQLRepository(GraphQLServiceAdapter()), - AccountRepository(accountDataSource), - FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())) + AccountRepository(AccountDataSource(AccountDaoAdapter())), + FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + coroutineScope.dispatcher, ) assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) + viewModel.setCurrentUser(Resource.success(null)) assertNull(viewModel.profile.getOrAwaitValue().data) assertEquals("", viewModel.username.getOrAwaitValue()) - viewModel.currentUser = Resource.success(null) + viewModel.setCurrentUser(Resource.success(null)) assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) } + @ExperimentalCoroutinesApi @Test fun testNoUsernameWithCurrentUser() { - // val state = SavedStateHandle( - // mutableMapOf( - // "username" to "test" - // ) - // ) - val userRepository = UserRepository(UserServiceAdapter()) - val friendshipRepository = FriendshipRepository(FriendshipServiceAdapter()) - val storiesRepository = StoriesRepository(StoriesServiceAdapter()) - val mediaRepository = MediaRepository(MediaServiceAdapter()) - val graphQLRepository = GraphQLRepository(GraphQLServiceAdapter()) - val accountDataSource = AccountDataSource(AccountDaoAdapter()) - val accountRepository = AccountRepository(accountDataSource) - val favoriteRepository = FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())) val viewModel = ProfileFragmentViewModel( SavedStateHandle(), - userRepository, - friendshipRepository, - storiesRepository, - mediaRepository, - graphQLRepository, - accountRepository, - favoriteRepository + UserRepository(UserServiceAdapter()), + FriendshipRepository(FriendshipServiceAdapter()), + StoriesRepository(StoriesServiceAdapter()), + MediaRepository(MediaServiceAdapter()), + GraphQLRepository(GraphQLServiceAdapter()), + AccountRepository(AccountDataSource(AccountDaoAdapter())), + FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + coroutineScope.dispatcher, ) assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) assertNull(viewModel.profile.getOrAwaitValue().data) val user = User() - viewModel.currentUser = Resource.success(user) + viewModel.setCurrentUser(Resource.success(user)) assertEquals(true, viewModel.isLoggedIn.getOrAwaitValue()) - assertEquals(user, viewModel.profile.getOrAwaitValue().data) + var profile = viewModel.profile.getOrAwaitValue() + while (profile.status == Resource.Status.LOADING) { + profile = viewModel.profile.getOrAwaitValue() + } + assertEquals(user, profile.data) + } + + @ExperimentalCoroutinesApi + @Test + fun testPublicUsernameWithNoCurrentUser() { + // username without `@` + val state = SavedStateHandle( + mutableMapOf( + "username" to testPublicUser.username + ) + ) + val graphQLRepository = object : GraphQLRepository(GraphQLServiceAdapter()) { + override suspend fun fetchUser(username: String): User { + return testPublicUser + } + } + val viewModel = ProfileFragmentViewModel( + state, + UserRepository(UserServiceAdapter()), + FriendshipRepository(FriendshipServiceAdapter()), + StoriesRepository(StoriesServiceAdapter()), + MediaRepository(MediaServiceAdapter()), + graphQLRepository, + AccountRepository(AccountDataSource(AccountDaoAdapter())), + FavoriteRepository(FavoriteDataSource(FavoriteDaoAdapter())), + coroutineScope.dispatcher, + ) + viewModel.setCurrentUser(Resource.success(null)) + assertEquals(false, viewModel.isLoggedIn.getOrAwaitValue()) + var profile = viewModel.profile.getOrAwaitValue() + while (profile.status == Resource.Status.LOADING) { + profile = viewModel.profile.getOrAwaitValue() + } + assertEquals(testPublicUser, profile.data) } } \ No newline at end of file