Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DO NOT MERGE - live data & coroutines experiments #770

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

super nit: It looks like a space got added here accidentally. :)

* Copyright 2015 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
Expand Down
4 changes: 2 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion dribbble/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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, _, _ ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -41,43 +44,49 @@ class ShotViewModel @Inject constructor(
private val getShareShotInfo: GetShareShotInfoUseCase,
private val dispatcherProvider: CoroutinesDispatcherProvider
) : ViewModel() {

private val _shotUiModel = MutableLiveData<ShotUiModel>()
val shotUiModel: LiveData<ShotUiModel>
get() = _shotUiModel

private val _openLink = MutableLiveData<Event<String>>()
val openLink: LiveData<Event<String>>
get() = _openLink

private val _shareShot = MutableLiveData<Event<ShareShotInfo>>()
val shareShot: LiveData<Event<ShareShotInfo>>
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<Unit>(Channel.CONFLATED)

private val shareShotRequest = Channel<Unit>(Channel.CONFLATED)

@ExperimentalCoroutinesApi
val events = viewModelScope.produce(dispatcherProvider.computation) {
while (true) {
val action = select<UserAction> {
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 {
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,38 @@
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
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

Expand All @@ -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 <T> LiveData<T>.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()))

Expand All @@ -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<ShotUiModel> { 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<String>? = 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)
Expand All @@ -120,18 +138,20 @@ class ShotViewModelTest {
viewModel.shareShotRequested()

// Then an event is raised with the expected info
val shareInfoEvent: Event<ShareShotInfo>? = 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<ShotUiModel> { 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()
Expand All @@ -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<ShotUiModel> { 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()

Expand All @@ -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(
Expand All @@ -190,8 +209,10 @@ class ShotViewModelTest {
repo,
createShotUiModel,
getShareShotInfoUseCase,
provideFakeCoroutinesDispatcherProvider(testCoroutineDispatcher,
testCoroutineDispatcher, testCoroutineDispatcher)
provideFakeCoroutinesDispatcherProvider(
testCoroutineDispatcher,
testCoroutineDispatcher, testCoroutineDispatcher
)
)
}
}
Loading