diff --git a/app/build.gradle b/app/build.gradle index eac705a89..5157c2627 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,4 +1,4 @@ -/* + /* * Copyright 2015 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); diff --git a/build.gradle b/build.gradle index b94fdfad8..6dce5aa1d 100644 --- a/build.gradle +++ b/build.gradle @@ -42,7 +42,7 @@ buildscript { scriptHandler -> 'kotlin' : '1.3.50', 'ktlint' : '0.24.0', 'legacyCoreUtils' : '1.0.0', - 'lifecycle' : '2.2.0-alpha03', + 'lifecycle' : '2.2.0-alpha05', 'material' : '1.1.0-alpha05', 'mockito' : '2.23.0', 'mockito_kotlin' : '2.0.0-RC3', @@ -60,7 +60,7 @@ buildscript { scriptHandler -> ] dependencies { - classpath 'com.android.tools.build:gradle:3.6.0-alpha07' + classpath 'com.android.tools.build:gradle:3.6.0-alpha12' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${versions.kotlin}" classpath "com.google.gms:google-services:${versions.googleServices}" classpath "io.fabric.tools:gradle:${versions.fabric}" diff --git a/dribbble/build.gradle b/dribbble/build.gradle index 908930084..0fdb5cab6 100644 --- a/dribbble/build.gradle +++ b/dribbble/build.gradle @@ -56,7 +56,7 @@ android { dependencies { implementation project(':app') implementation project(':core') - + implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.lifecycle}" implementation "com.android.support:customtabs:${versions.supportLibrary}" implementation "com.android.support:palette-v7:${versions.supportLibrary}" implementation "com.github.bumptech.glide:recyclerview-integration:${versions.glide}" diff --git a/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotActivity.kt b/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotActivity.kt index 034a8d9e7..a20f14d83 100644 --- a/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotActivity.kt +++ b/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotActivity.kt @@ -30,6 +30,7 @@ import androidx.core.app.ShareCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope import androidx.palette.graphics.Palette import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException @@ -42,7 +43,6 @@ import io.plaidapp.core.util.ColorUtils import io.plaidapp.core.util.ViewUtils import io.plaidapp.core.util.customtabs.CustomTabActivityHelper import io.plaidapp.core.util.delegates.contentView -import io.plaidapp.core.util.event.EventObserver import io.plaidapp.core.util.glide.getBitmap import io.plaidapp.dribbble.R import io.plaidapp.dribbble.dagger.inject @@ -119,13 +119,24 @@ class ShotActivity : AppCompatActivity() { largeAvatarSize = resources.getDimensionPixelSize(io.plaidapp.R.dimen.large_avatar_size) binding.viewModel = viewModel.also { vm -> - vm.openLink.observe(this, EventObserver { openLink(it) }) - vm.shareShot.observe(this, EventObserver { shareShot(it) }) vm.shotUiModel.observe(this, Observer { binding.uiModel = it }) } + lifecycleScope.launchWhenStarted { + for (event in viewModel.events) { + when (event) { + is ShotViewModel.UserAction.OpenLink -> { + openLink(event.url) + } + is ShotViewModel.UserAction.ShareShot -> { + shareShot(event.info) + } + } + } + } + binding.shotLoadListener = shotLoadListener binding.apply { bodyScroll.setOnScrollChangeListener { _, _, scrollY, _, _ -> diff --git a/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotViewModel.kt b/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotViewModel.kt index 1080b8b8c..8657af20e 100644 --- a/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotViewModel.kt +++ b/dribbble/src/main/java/io/plaidapp/dribbble/ui/shot/ShotViewModel.kt @@ -16,19 +16,22 @@ package io.plaidapp.dribbble.ui.shot -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow +import androidx.lifecycle.liveData import androidx.lifecycle.viewModelScope import io.plaidapp.core.data.CoroutinesDispatcherProvider import io.plaidapp.core.data.Result import io.plaidapp.core.dribbble.data.ShotsRepository -import io.plaidapp.core.dribbble.data.api.model.Shot -import io.plaidapp.core.util.event.Event import io.plaidapp.dribbble.domain.CreateShotUiModelUseCase import io.plaidapp.dribbble.domain.GetShareShotInfoUseCase import io.plaidapp.dribbble.domain.ShareShotInfo -import kotlinx.coroutines.launch +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.yield import javax.inject.Inject /** @@ -41,43 +44,49 @@ class ShotViewModel @Inject constructor( private val getShareShotInfo: GetShareShotInfoUseCase, private val dispatcherProvider: CoroutinesDispatcherProvider ) : ViewModel() { - - private val _shotUiModel = MutableLiveData() - val shotUiModel: LiveData - get() = _shotUiModel - - private val _openLink = MutableLiveData>() - val openLink: LiveData> - get() = _openLink - - private val _shareShot = MutableLiveData>() - val shareShot: LiveData> - get() = _shareShot - - init { + val shotUiModel = liveData( + context = viewModelScope.coroutineContext + ) { val result = shotsRepository.getShot(shotId) if (result is Result.Success) { - _shotUiModel.value = result.data.toShotUiModel() - processUiModel(result.data) + emit(result.data.toShotUiModel()) + // this is for testing so that it can see two values :/. + yield() + emit(createShotUiModel(result.data)) } else { // TODO re-throw Error.exception once Loading state removed. throw IllegalStateException("Could not retrieve shot $shotId") } } - fun shareShotRequested() { - _shotUiModel.value?.let { model -> - viewModelScope.launch(dispatcherProvider.io) { - val shareInfo = getShareShotInfo(model) - _shareShot.postValue(Event(shareInfo)) + private val openLinkRequest = Channel(Channel.CONFLATED) + + private val shareShotRequest = Channel(Channel.CONFLATED) + + @ExperimentalCoroutinesApi + val events = viewModelScope.produce(dispatcherProvider.computation) { + while (true) { + val action = select { + shareShotRequest.onReceive { + val model = shotUiModel.asFlow().first() + val shareInfo = getShareShotInfo(model) + UserAction.ShareShot(shareInfo) + } + openLinkRequest.onReceive { + val model = shotUiModel.asFlow().first() + UserAction.OpenLink(model.url) + } } + send(action) } } + fun shareShotRequested() { + shareShotRequest.offer(Unit) + } + fun viewShotRequested() { - _shotUiModel.value?.let { model -> - _openLink.value = Event(model.url) - } + openLinkRequest.offer(Unit) } fun getAssistWebUrl(): String { @@ -88,10 +97,8 @@ class ShotViewModel @Inject constructor( return shotUiModel.value?.id ?: -1L } - private fun processUiModel(shot: Shot) { - viewModelScope.launch(dispatcherProvider.main) { - val uiModel = createShotUiModel(shot) - _shotUiModel.value = uiModel - } + sealed class UserAction { + class ShareShot(val info: ShareShotInfo) : UserAction() + class OpenLink(val url: String) : UserAction() } } diff --git a/dribbble/src/test/java/io/plaidapp/dribbble/ui/shot/ShotViewModelTest.kt b/dribbble/src/test/java/io/plaidapp/dribbble/ui/shot/ShotViewModelTest.kt index 24e903edf..cf1e5ef07 100644 --- a/dribbble/src/test/java/io/plaidapp/dribbble/ui/shot/ShotViewModelTest.kt +++ b/dribbble/src/test/java/io/plaidapp/dribbble/ui/shot/ShotViewModelTest.kt @@ -17,6 +17,8 @@ package io.plaidapp.dribbble.ui.shot import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.LiveData +import androidx.lifecycle.asFlow import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock @@ -24,22 +26,29 @@ import com.nhaarman.mockitokotlin2.whenever import io.plaidapp.core.data.Result import io.plaidapp.core.dribbble.data.ShotsRepository import io.plaidapp.core.dribbble.data.api.model.Shot -import io.plaidapp.core.util.event.Event import io.plaidapp.dribbble.domain.CreateShotUiModelUseCase import io.plaidapp.dribbble.domain.GetShareShotInfoUseCase import io.plaidapp.dribbble.domain.ShareShotInfo import io.plaidapp.dribbble.testShot import io.plaidapp.dribbble.testShotUiModel -import io.plaidapp.test.shared.LiveDataTestUtil +import io.plaidapp.dribbble.ui.shot.ShotViewModel.UserAction.OpenLink +import io.plaidapp.dribbble.ui.shot.ShotViewModel.UserAction.ShareShot import io.plaidapp.test.shared.provideFakeCoroutinesDispatcherProvider +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.test.runBlockingTest import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.runBlockingTest +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull -import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,24 +70,34 @@ class ShotViewModelTest { } private val testCoroutineDispatcher = TestCoroutineDispatcher() + @Before + fun initMainDispatcher() { + Dispatchers.setMain(testCoroutineDispatcher) + } + @After fun tearDown() { testCoroutineDispatcher.cleanupTestCoroutines() } @Test - fun loadShot_existsInRepo() { + fun loadShot_existsInRepo() = testCoroutineDispatcher.runBlockingTest { // Given that the repo successfully returns the requested shot // When view model is constructed val viewModel = withViewModel() - // Then a shotUiModel is present - val result: ShotUiModel? = LiveDataTestUtil.getValue(viewModel.shotUiModel) + val result: ShotUiModel? = viewModel.shotUiModel.currentOrNextValue() assertNotNull(result) } + private suspend fun LiveData.currentOrNextValue() = try { + this.asFlow().first() + } catch (_: NoSuchElementException) { + null + } + @Test(expected = IllegalStateException::class) - fun loadShot_notInRepo() { + fun loadShot_notInRepo() = testCoroutineDispatcher.runBlockingTest { // Given that the repo fails to return the requested shot whenever(repo.getShot(shotId)).thenReturn(Result.Error(Exception())) @@ -89,29 +108,28 @@ class ShotViewModelTest { createShotUiModel, getShareShotInfoUseCase, provideFakeCoroutinesDispatcherProvider() - ) + ).shotUiModel.currentOrNextValue() // Then it throws } @Test - fun shotClicked_sendsOpenLinkEvent() = runBlocking { + fun shotClicked_sendsOpenLinkEvent() = testCoroutineDispatcher.runBlockingTest { // Given a view model with a shot with a known URL val url = "https://dribbble.com/shots/2344334-Plaid-Product-Icon" val mockShotUiModel = mock { on { this.url } doReturn url } whenever(createShotUiModel.invoke(any())).thenReturn(mockShotUiModel) val viewModel = withViewModel(shot = testShot.copy(htmlUrl = url)) - // When there is a request to view the shot viewModel.viewShotRequested() // Then an event is emitted to open the given url - val openLinkEvent: Event? = LiveDataTestUtil.getValue(viewModel.openLink) + val openLinkEvent = viewModel.events.poll() assertNotNull(openLinkEvent) - assertEquals(url, openLinkEvent!!.peek()) + assertEquals(url, (openLinkEvent as OpenLink).url) } @Test - fun shotShareClicked_sendsShareInfoEvent() { + fun shotShareClicked_sendsShareInfoEvent() = testCoroutineDispatcher.runBlockingTest { // Given a VM with a mocked use case which return a known Share Info object val expected = ShareShotInfo(mock(), "Title", "Share Text", "Mime") val viewModel = withViewModel(shareInfo = expected) @@ -120,18 +138,20 @@ class ShotViewModelTest { viewModel.shareShotRequested() // Then an event is raised with the expected info - val shareInfoEvent: Event? = LiveDataTestUtil.getValue(viewModel.shareShot) + val shareInfoEvent = viewModel.events.poll() assertNotNull(shareInfoEvent) - assertEquals(expected, shareInfoEvent!!.peek()) + assertEquals(expected, (shareInfoEvent as ShareShot).info) } @Test - fun getAssistWebUrl_returnsShotUrl() { + fun getAssistWebUrl_returnsShotUrl() = testCoroutineDispatcher.runBlockingTest { // Given a view model with a shot with a known URL val url = "https://dribbble.com/shots/2344334-Plaid-Product-Icon" val mockShotUiModel = mock { on { this.url } doReturn url } - runBlocking { whenever(createShotUiModel.invoke(any())).thenReturn(mockShotUiModel) } + whenever(createShotUiModel.invoke(any())).thenReturn(mockShotUiModel) val viewModel = withViewModel(shot = testShot.copy(htmlUrl = url)) + // ensure it is observed + viewModel.shotUiModel.currentOrNextValue() // When there is a request to share the shot val assistWebUrl = viewModel.getAssistWebUrl() @@ -141,13 +161,14 @@ class ShotViewModelTest { } @Test - fun getShotId_returnsId() { + fun getShotId_returnsId() = testCoroutineDispatcher.runBlockingTest { // Given a view model with a shot with a known ID val id = 1234L val mockShotUiModel = mock { on { this.id } doReturn id } - runBlocking { whenever(createShotUiModel.invoke(any())).thenReturn(mockShotUiModel) } + whenever(createShotUiModel.invoke(any())).thenReturn(mockShotUiModel) val viewModel = withViewModel(shot = testShot.copy(id = id)) - + // ensure it is observed + viewModel.shotUiModel.currentOrNextValue() // When there is a request to share the shot val shotId = viewModel.getShotId() @@ -160,19 +181,17 @@ class ShotViewModelTest { // Given coroutines have not started yet and the View Model is created testCoroutineDispatcher.pauseDispatcher() val viewModel = withViewModel() - - // Then the fast result has been emitted - val fastResult: ShotUiModel? = LiveDataTestUtil.getValue(viewModel.shotUiModel) - assertNotNull(fastResult) - assertTrue(fastResult!!.formattedDescription.isEmpty()) - - // When the coroutine starts - testCoroutineDispatcher.resumeDispatcher() - - // Then the slow result has been emitted - val slowResult: ShotUiModel? = LiveDataTestUtil.getValue(viewModel.shotUiModel) - assertNotNull(slowResult) - assertTrue(slowResult!!.formattedDescription.isNotEmpty()) + val collection = async { + viewModel.shotUiModel.asFlow().take(2) + .onEach { + println("hello $it") + } + .toList() + } + testCoroutineDispatcher.runCurrent() + assertEquals(collection.await().map { + it.formattedDescription.isEmpty() + }, listOf(true, false)) } private fun withViewModel( @@ -190,8 +209,10 @@ class ShotViewModelTest { repo, createShotUiModel, getShareShotInfoUseCase, - provideFakeCoroutinesDispatcherProvider(testCoroutineDispatcher, - testCoroutineDispatcher, testCoroutineDispatcher) + provideFakeCoroutinesDispatcherProvider( + testCoroutineDispatcher, + testCoroutineDispatcher, testCoroutineDispatcher + ) ) } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b0acbdcd7..1c16bafbf 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Mon Oct 07 18:04:50 PDT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-5.5-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.1-all.zip