diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt index a4f246dd7b..0fb0c725f0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerFragment.kt @@ -4,6 +4,7 @@ import android.os.Bundle import android.view.View import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.LiveDataReactiveStreams import androidx.viewpager2.widget.ViewPager2 import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.recipients.RecipientId @@ -40,7 +41,7 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie storyPager.isUserInputEnabled = !it } - viewModel.state.observe(viewLifecycleOwner) { state -> + LiveDataReactiveStreams.fromPublisher(viewModel.state).observe(viewLifecycleOwner) { state -> adapter.setPages(state.pages) if (state.pages.isNotEmpty() && storyPager.currentItem != state.page) { storyPager.setCurrentItem(state.page, state.previousPage > -1) @@ -65,11 +66,11 @@ class StoryViewerFragment : Fragment(R.layout.stories_viewer_fragment), StoryVie } override fun onGoToPreviousStory(recipientId: RecipientId) { - viewModel.onGoToPreviousStory(recipientId) + viewModel.onGoToPrevious(recipientId) } override fun onFinishedPosts(recipientId: RecipientId) { - viewModel.onFinishedPosts(recipientId) + viewModel.onGoToNext(recipientId) } override fun onStoryHidden(recipientId: RecipientId) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt index b06ae8a8ff..34577fbc05 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerRepository.kt @@ -8,7 +8,10 @@ import org.thoughtcrime.securesms.database.model.StoryResult import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId -class StoryViewerRepository { +/** + * Open for testing + */ +open class StoryViewerRepository { fun getStories(): Single> { return Single.fromCallable { val storyResults: List = SignalDatabase.mms.orderedStoryRecipientsAndIds.distinctBy { it.recipientId } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt index e377a39aef..9324ccc2c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModel.kt @@ -4,21 +4,23 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.livedata.Store -import kotlin.math.min +import org.thoughtcrime.securesms.util.rx.RxStore +import kotlin.math.max class StoryViewerViewModel( private val startRecipientId: RecipientId, private val repository: StoryViewerRepository ) : ViewModel() { - private val store = Store(StoryViewerState()) + private val store = RxStore(StoryViewerState()) private val disposables = CompositeDisposable() - val state: LiveData = store.stateLiveData + val stateSnapshot: StoryViewerState get() = store.state + val state: Flowable = store.stateFlowable private val scrollStatePublisher: MutableLiveData = MutableLiveData(false) val isScrolling: LiveData = scrollStatePublisher @@ -66,7 +68,7 @@ class StoryViewerViewModel( } } - fun onFinishedPosts(recipientId: RecipientId) { + fun onGoToNext(recipientId: RecipientId) { store.update { if (it.pages[it.page] == recipientId) { updatePages(it, it.page + 1) @@ -76,10 +78,10 @@ class StoryViewerViewModel( } } - fun onGoToPreviousStory(recipientId: RecipientId) { + fun onGoToPrevious(recipientId: RecipientId) { store.update { if (it.pages[it.page] == recipientId) { - updatePages(it, min(0, it.page - 1)) + updatePages(it, max(0, it.page - 1)) } else { it } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index c19b97d768..9fd094c676 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -238,7 +238,7 @@ class StoryViewerPageFragment : viewModel.setIsUserScrollingParent(isScrolling) } - sharedViewModel.state.observe(viewLifecycleOwner) { parentState -> + LiveDataReactiveStreams.fromPublisher(sharedViewModel.state).observe(viewLifecycleOwner) { parentState -> if (parentState.pages.size <= parentState.page) { viewModel.setIsSelectedPage(false) } else if (storyRecipientId == parentState.pages[parentState.page]) { @@ -753,7 +753,7 @@ class StoryViewerPageFragment : } override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean { - val isFirstStory = sharedViewModel.state.value?.page == 0 + val isFirstStory = sharedViewModel.stateSnapshot.page == 0 val isXMagnitudeGreaterThanYMagnitude = abs(distanceX) > abs(distanceY) || viewToTranslate.translationX > 0f val isFirstAndHasYTranslationOrNegativeY = isFirstStory && (viewToTranslate.translationY > 0f || distanceY < 0f) diff --git a/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt new file mode 100644 index 0000000000..edd6f99e24 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/stories/viewer/StoryViewerViewModelTest.kt @@ -0,0 +1,147 @@ +package org.thoughtcrime.securesms.stories.viewer + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.thoughtcrime.securesms.recipients.RecipientId + +class StoryViewerViewModelTest { + private val testScheduler = TestScheduler() + private val repository: StoryViewerRepository = mock() + + @Before + fun setUp() { + RxJavaPlugins.setInitComputationSchedulerHandler { testScheduler } + RxJavaPlugins.setComputationSchedulerHandler { testScheduler } + } + + @After + fun tearDown() { + RxJavaPlugins.reset() + } + + @Test + fun `Given five stories, when I initialize with story 2, then I expect to be on the right page`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = RecipientId.from(2L) + whenever(repository.getStories()).doReturn(Single.just(stories)) + + // WHEN + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // THEN + val expectedStartIndex = testSubject.stateSnapshot.pages.indexOf(startStory) + val actualStartIndex = testSubject.stateSnapshot.page + + assertEquals(expectedStartIndex, actualStartIndex) + } + + @Test + fun `Given five stories and am on 1, when I onGoToNext, then I expect to go to 2`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = RecipientId.from(1L) + whenever(repository.getStories()).doReturn(Single.just(stories)) + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // WHEN + testSubject.onGoToNext(RecipientId.from(1L)) + testScheduler.triggerActions() + + // THEN + val expectedIndex = 1 + val actualIndex = testSubject.stateSnapshot.page + + assertEquals(expectedIndex, actualIndex) + } + + @Test + fun `Given five stories and am on last, when I onGoToNext, then I expect to go to size`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = stories.last() + whenever(repository.getStories()).doReturn(Single.just(stories)) + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // WHEN + testSubject.onGoToNext(startStory) + testScheduler.triggerActions() + + // THEN + val expectedIndex = stories.size + val actualIndex = testSubject.stateSnapshot.page + + assertEquals(expectedIndex, actualIndex) + } + + @Test + fun `Given five stories and am on last, when I onGoToPrevious, then I expect to go to last - 1`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = stories.last() + whenever(repository.getStories()).doReturn(Single.just(stories)) + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // WHEN + testSubject.onGoToPrevious(startStory) + testScheduler.triggerActions() + + // THEN + val expectedIndex = stories.lastIndex - 1 + val actualIndex = testSubject.stateSnapshot.page + + assertEquals(expectedIndex, actualIndex) + } + + @Test + fun `Given five stories and am on first, when I onGoToPrevious, then I expect stay at 0`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = stories.first() + whenever(repository.getStories()).doReturn(Single.just(stories)) + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // WHEN + testSubject.onGoToPrevious(startStory) + testScheduler.triggerActions() + + // THEN + val expectedIndex = 0 + val actualIndex = testSubject.stateSnapshot.page + + assertEquals(expectedIndex, actualIndex) + } + + @Test + fun `Given five stories and am on first, when I setSelectedPage, then I expect to go to the page I selected`() { + // GIVEN + val stories: List = (1L..5L).map(RecipientId::from) + val startStory = stories.first() + whenever(repository.getStories()).doReturn(Single.just(stories)) + val testSubject = StoryViewerViewModel(startStory, repository) + testScheduler.triggerActions() + + // WHEN + testSubject.setSelectedPage(2) + testScheduler.triggerActions() + + // THEN + val expectedIndex = 2 + val actualIndex = testSubject.stateSnapshot.page + + assertEquals(expectedIndex, actualIndex) + } +}