From e5e83736d3feff8ac9cc4ae38fad0fa827a6b21d Mon Sep 17 00:00:00 2001 From: Allan Wang Date: Fri, 28 Dec 2018 20:19:18 -0500 Subject: Switch remaining primary observables --- app/build.gradle | 2 +- .../pitchedapps/frost/activities/AboutActivity.kt | 3 +- .../frost/activities/BaseMainActivity.kt | 6 +- .../pitchedapps/frost/activities/MainActivity.kt | 16 +- .../frost/activities/WebOverlayActivity.kt | 9 +- .../frost/contracts/ActivityContract.kt | 4 +- .../frost/contracts/FrostContentContract.kt | 3 - .../pitchedapps/frost/fragments/FragmentBase.kt | 56 +++--- .../frost/fragments/FragmentContract.kt | 12 +- .../frost/fragments/RecyclerFragmentBase.kt | 29 --- .../frost/fragments/RecyclerFragments.kt | 1 - .../com/pitchedapps/frost/utils/KotlinUtils.kt | 20 +++ .../pitchedapps/frost/views/FrostContentView.kt | 2 - .../pitchedapps/frost/views/FrostRecyclerView.kt | 2 - .../pitchedapps/frost/web/FrostChromeClients.kt | 7 +- .../pitchedapps/frost/web/FrostWebViewClients.kt | 1 - .../com/pitchedapps/frost/utils/CoroutineTest.kt | 194 +++++++++++++++++++++ .../frost/views/FrostContentViewAsyncTest.kt | 130 -------------- 18 files changed, 276 insertions(+), 221 deletions(-) create mode 100644 app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt create mode 100644 app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt delete mode 100644 app/src/test/kotlin/com/pitchedapps/frost/views/FrostContentViewAsyncTest.kt diff --git a/app/build.gradle b/app/build.gradle index 75ffed98..84d2d694 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -101,7 +101,7 @@ android { resValue "string", "frost_web", "Frost Web Debug" ext.enableBugsnag = false - kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental"] + kotlinOptions.freeCompilerArgs += ["-Xuse-experimental=kotlin.Experimental", "-XXLanguage:+InlineClasses"] } releaseTest { minifyEnabled true diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt index a110071c..283477d7 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/AboutActivity.kt @@ -176,7 +176,8 @@ class AboutActivity : AboutActivityBase(null, { } val set = ConstraintSet() set.clone(container) - set.createHorizontalChain(ConstraintSet.PARENT_ID, + set.createHorizontalChain( + ConstraintSet.PARENT_ID, ConstraintSet.LEFT, ConstraintSet.PARENT_ID, ConstraintSet.RIGHT, diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt index 20b5727f..78f6bfb9 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/BaseMainActivity.kt @@ -341,7 +341,7 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, private fun refreshAll() { L.d { "Refresh all" } - fragmentSubject.onNext(REQUEST_REFRESH) + fragmentChannel.offer(REQUEST_REFRESH) } override fun onCreateOptionsMenu(menu: Menu): Boolean { @@ -424,9 +424,9 @@ abstract class BaseMainActivity : BaseActivity(), MainActivityContract, /* * These results can be stacked */ - if (resultCode and REQUEST_REFRESH > 0) fragmentSubject.onNext(REQUEST_REFRESH) + if (resultCode and REQUEST_REFRESH > 0) fragmentChannel.offer(REQUEST_REFRESH) if (resultCode and REQUEST_NAV > 0) frostNavigationBar() - if (resultCode and REQUEST_TEXT_ZOOM > 0) fragmentSubject.onNext(REQUEST_TEXT_ZOOM) + if (resultCode and REQUEST_TEXT_ZOOM > 0) fragmentChannel.offer(REQUEST_TEXT_ZOOM) if (resultCode and REQUEST_SEARCH > 0) invalidateOptionsMenu() } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt index d03c6496..cc375800 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/MainActivity.kt @@ -24,12 +24,15 @@ import com.pitchedapps.frost.views.BadgedIcon import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.schedulers.Schedulers import io.reactivex.subjects.PublishSubject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BroadcastChannel import org.jsoup.Jsoup import java.util.concurrent.TimeUnit +@UseExperimental(ExperimentalCoroutinesApi::class) class MainActivity : BaseMainActivity() { - override val fragmentSubject = PublishSubject.create() + override val fragmentChannel = BroadcastChannel(10) var lastPosition = -1 val headerBadgeObservable = PublishSubject.create() @@ -43,8 +46,8 @@ class MainActivity : BaseMainActivity() { override fun onPageSelected(position: Int) { super.onPageSelected(position) if (lastPosition == position) return - if (lastPosition != -1) fragmentSubject.onNext(-(lastPosition + 1)) - fragmentSubject.onNext(position) + if (lastPosition != -1) fragmentChannel.offer(-(lastPosition + 1)) + fragmentChannel.offer(position) lastPosition = position } @@ -62,7 +65,7 @@ class MainActivity : BaseMainActivity() { } } }) - viewPager.post { fragmentSubject.onNext(0); lastPosition = 0 } //trigger hook so title is set + viewPager.post { fragmentChannel.offer(0); lastPosition = 0 } //trigger hook so title is set } private fun setupTabs() { @@ -101,8 +104,9 @@ class MainActivity : BaseMainActivity() { } }.disposeOnDestroy() adapter.pages.forEach { - tabs.addTab(tabs.newTab() - .setCustomView(BadgedIcon(this).apply { iicon = it.icon }) + tabs.addTab( + tabs.newTab() + .setCustomView(BadgedIcon(this).apply { iicon = it.icon }) ) } } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt index 816524ba..19a1109f 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/activities/WebOverlayActivity.kt @@ -67,13 +67,12 @@ import com.pitchedapps.frost.utils.Showcase import com.pitchedapps.frost.utils.frostSnackbar import com.pitchedapps.frost.utils.materialDialogThemed import com.pitchedapps.frost.utils.setFrostColors +import com.pitchedapps.frost.utils.uniqueOnly import com.pitchedapps.frost.views.FrostContentWeb import com.pitchedapps.frost.views.FrostVideoViewer import com.pitchedapps.frost.views.FrostWebView -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.HttpUrl @@ -91,6 +90,7 @@ import okhttp3.HttpUrl * Used by notifications. Unlike the other overlays, this runs as a singleInstance * Going back will bring you back to the previous app */ +@UseExperimental(ExperimentalCoroutinesApi::class) class FrostWebActivity : WebOverlayActivityBase(false) { override fun onCreate(savedInstanceState: Bundle?) { @@ -151,6 +151,7 @@ class WebOverlayBasicActivity : WebOverlayActivityBase(true) class WebOverlayActivity : WebOverlayActivityBase(false) @SuppressLint("Registered") +@UseExperimental(ExperimentalCoroutinesApi::class) open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseActivity(), ActivityContract, FrostContentContainer, VideoViewHolder, FileChooserContract by FileChooserDelegate() { @@ -203,7 +204,7 @@ open class WebOverlayActivityBase(private val forceBasicAgent: Boolean) : BaseAc content.bind(this) - val titleReceiver = content.titleChannel.openSubscription() + val titleReceiver = content.titleChannel.openSubscription().uniqueOnly(this) launch { for (t in titleReceiver) { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt index 2ce83871..9a70436e 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/ActivityContract.kt @@ -19,7 +19,7 @@ package com.pitchedapps.frost.contracts import com.mikepenz.iconics.typeface.IIcon import com.pitchedapps.frost.activities.MainActivity import com.pitchedapps.frost.fragments.BaseFragment -import io.reactivex.subjects.PublishSubject +import kotlinx.coroutines.channels.BroadcastChannel /** * All the contracts for [MainActivity] @@ -27,7 +27,7 @@ import io.reactivex.subjects.PublishSubject interface ActivityContract : FileChooserActivityContract interface MainActivityContract : ActivityContract, MainFabContract { - val fragmentSubject: PublishSubject + val fragmentChannel: BroadcastChannel fun setTitle(res: Int) fun setTitle(text: CharSequence) /** diff --git a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt index 0f8c49d3..8a6e57af 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/contracts/FrostContentContract.kt @@ -18,12 +18,9 @@ package com.pitchedapps.frost.contracts import android.view.View import com.pitchedapps.frost.facebook.FbItem -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.PublishSubject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.Channel /** * Created by Allan Wang on 20/12/17. diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt index 2c46edbc..c5880840 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentBase.kt @@ -39,12 +39,13 @@ import com.pitchedapps.frost.utils.Prefs import com.pitchedapps.frost.utils.REQUEST_REFRESH import com.pitchedapps.frost.utils.REQUEST_TEXT_ZOOM import com.pitchedapps.frost.utils.frostEvent -import io.reactivex.android.schedulers.AndroidSchedulers -import io.reactivex.disposables.Disposable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlin.coroutines.CoroutineContext /** @@ -87,7 +88,7 @@ abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, Dyna override var valid: Boolean get() = arguments!!.getBoolean(ARG_VALID, true) set(value) { - if (value || this is WebFragment) return + if (!isActive || value || this is WebFragment) return arguments!!.putBoolean(ARG_VALID, value) L.e { "Invalidating position $position" } frostEvent( @@ -98,7 +99,7 @@ abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, Dyna } override var firstLoad: Boolean = true - private var activityDisposable: Disposable? = null + private var activityReceiver: ReceiveChannel? = null private var onCreateRunnable: ((FragmentContract) -> Unit)? = null override var content: FrostContentParent? = null @@ -154,29 +155,34 @@ abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, Dyna (context as? MainActivityContract)?.setTitle(title) } - override fun attachMainObservable(contract: MainActivityContract): Disposable = - contract.fragmentSubject.observeOn(AndroidSchedulers.mainThread()).subscribe { - when (it) { - REQUEST_REFRESH -> { - core?.apply { - clearHistory() - firstLoad = true - firstLoadRequest() + override fun attachMainObservable(contract: MainActivityContract): ReceiveChannel { + val receiver = contract.fragmentChannel.openSubscription() + launch { + for (flag in receiver) { + when (flag) { + REQUEST_REFRESH -> { + core?.apply { + clearHistory() + firstLoad = true + firstLoadRequest() + } + } + position -> { + contract.setTitle(baseEnum.titleId) + updateFab(contract) + core?.active = true + } + -(position + 1) -> { + core?.active = false + } + REQUEST_TEXT_ZOOM -> { + reloadTextSize() } - } - position -> { - contract.setTitle(baseEnum.titleId) - updateFab(contract) - core?.active = true - } - -(position + 1) -> { - core?.active = false - } - REQUEST_TEXT_ZOOM -> { - reloadTextSize() } } } + return receiver + } override fun updateFab(contract: MainFabContract) { contract.hideFab() // default @@ -195,14 +201,14 @@ abstract class BaseFragment : Fragment(), CoroutineScope, FragmentContract, Dyna } override fun detachMainObservable() { - activityDisposable?.dispose() + activityReceiver?.cancel() } override fun onAttach(context: Context) { super.onAttach(context) detachMainObservable() if (context is MainActivityContract) - activityDisposable = attachMainObservable(context) + activityReceiver = attachMainObservable(context) } override fun onDetach() { diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt index eaef17e5..10c612c5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/FragmentContract.kt @@ -22,7 +22,7 @@ import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.contracts.MainActivityContract import com.pitchedapps.frost.contracts.MainFabContract import com.pitchedapps.frost.views.FrostRecyclerView -import io.reactivex.disposables.Disposable +import kotlinx.coroutines.channels.ReceiveChannel /** * Created by Allan Wang on 2017-11-07. @@ -34,8 +34,9 @@ interface FragmentContract : FrostContentContainer { /** * Defines whether the fragment is valid in the viewpager - * Or if it needs to be recreated - * May be called from any thread to toggle status + * or if it needs to be recreated + * May be called from any thread to toggle status. + * Note that calls beyond the fragment lifecycle will be ignored */ var valid: Boolean @@ -75,9 +76,10 @@ interface FragmentContract : FrostContentContainer { /** * Call whenever a fragment is attached so that it may listen - * to activity emissions + * to activity emissions. + * Returns a means of closing the listener, which can be called from [detachMainObservable] */ - fun attachMainObservable(contract: MainActivityContract): Disposable + fun attachMainObservable(contract: MainActivityContract): ReceiveChannel /** * Call when fragment is detached so that any existing diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt index ddb9fde6..c03ac0e2 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragmentBase.kt @@ -21,7 +21,6 @@ import com.mikepenz.fastadapter.FastAdapter import com.mikepenz.fastadapter.IItem import com.mikepenz.fastadapter.adapters.ItemAdapter import com.mikepenz.fastadapter.adapters.ModelAdapter -import com.mikepenz.fastadapter_extensions.items.ProgressItem import com.pitchedapps.frost.R import com.pitchedapps.frost.facebook.FbCookie import com.pitchedapps.frost.facebook.parsers.FrostParser @@ -142,31 +141,3 @@ abstract class FrostParserFragment> : RecyclerFragme return@withContext items } } - -//abstract class PagedRecyclerFragment> : RecyclerFragment() { -// -// var allowPagedLoading = true -// -// val footerAdapter = ItemAdapter() -// -// val footerScrollListener = object : EndlessRecyclerOnScrollListener(footerAdapter) { -// override fun onLoadMore(currentPage: Int) { -// TODO("not implemented") -// -// } -// -// } -// -// override fun getAdapter() = fastAdapter(adapter, footerAdapter) -// -// override fun bindImpl(recyclerView: FrostRecyclerView) { -// recyclerView.addOnScrollListener(footerScrollListener) -// } -// -// override fun reload(progress: (Int) -> Unit, callback: (Boolean) -> Unit) { -// footerScrollListener. -// super.reload(progress, callback) -// } -//} - -class FrostProgress : ProgressItem() diff --git a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt index 45cf2290..f7ed9937 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/fragments/RecyclerFragments.kt @@ -37,7 +37,6 @@ import com.pitchedapps.frost.iitems.NotificationIItem import com.pitchedapps.frost.utils.frostJsoup import com.pitchedapps.frost.views.FrostRecyclerView import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.withContext /** diff --git a/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt b/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt new file mode 100644 index 00000000..320aeb69 --- /dev/null +++ b/app/src/main/kotlin/com/pitchedapps/frost/utils/KotlinUtils.kt @@ -0,0 +1,20 @@ +package com.pitchedapps.frost.utils + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.produce +import kotlinx.coroutines.isActive + +@UseExperimental(ExperimentalCoroutinesApi::class) +fun ReceiveChannel.uniqueOnly(scope: CoroutineScope): ReceiveChannel = scope.produce { + var previous: T? = null + for (current in this@uniqueOnly) { + if (!scope.isActive) { + cancel() + } else if (previous != current) { + previous = current + send(current) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt index 38591d38..9619eecc 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostContentView.kt @@ -40,8 +40,6 @@ import com.pitchedapps.frost.facebook.WEB_LOAD_DELAY import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.Prefs import io.reactivex.disposables.Disposable -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.PublishSubject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi diff --git a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt index 2ba78c5e..f7cb2214 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/views/FrostRecyclerView.kt @@ -23,13 +23,11 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import ca.allanwang.kau.utils.circularReveal import ca.allanwang.kau.utils.fadeOut -import com.pitchedapps.frost.R.string.reload import com.pitchedapps.frost.contracts.FrostContentContainer import com.pitchedapps.frost.contracts.FrostContentCore import com.pitchedapps.frost.contracts.FrostContentParent import com.pitchedapps.frost.fragments.RecyclerContentContract import com.pitchedapps.frost.utils.Prefs -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch /** diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt index f3538ec3..da90e7e5 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostChromeClients.kt @@ -29,9 +29,6 @@ import com.pitchedapps.frost.contracts.ActivityContract import com.pitchedapps.frost.utils.L import com.pitchedapps.frost.utils.frostSnackbar import com.pitchedapps.frost.views.FrostWebView -import io.reactivex.subjects.BehaviorSubject -import io.reactivex.subjects.Subject -import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.channels.SendChannel /** @@ -47,7 +44,6 @@ class FrostChromeClient(web: FrostWebView) : WebChromeClient() { private val progress: SendChannel = web.parent.progressChannel private val title: SendChannel = web.parent.titleChannel - private var prevTitle: String? = null private val activity = (web.context as? ActivityContract) private val context = web.context!! @@ -58,8 +54,7 @@ class FrostChromeClient(web: FrostWebView) : WebChromeClient() { override fun onReceivedTitle(view: WebView, title: String) { super.onReceivedTitle(view, title) - if (title.startsWith("http") || prevTitle == title) return - prevTitle = title + if (title.startsWith("http")) return this.title.offer(title) } diff --git a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt index 5a137c44..cb212b0a 100644 --- a/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt +++ b/app/src/main/kotlin/com/pitchedapps/frost/web/FrostWebViewClients.kt @@ -40,7 +40,6 @@ import com.pitchedapps.frost.utils.isIndirectImageUrl import com.pitchedapps.frost.utils.launchImageActivity import com.pitchedapps.frost.utils.resolveActivityForUri import com.pitchedapps.frost.views.FrostWebView -import io.reactivex.subjects.Subject import kotlinx.coroutines.channels.SendChannel import org.jetbrains.anko.withAlpha diff --git a/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt new file mode 100644 index 00000000..f930e529 --- /dev/null +++ b/app/src/test/kotlin/com/pitchedapps/frost/utils/CoroutineTest.kt @@ -0,0 +1,194 @@ +package com.pitchedapps.frost.utils + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BroadcastChannel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.channels.count +import kotlinx.coroutines.delay +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.util.concurrent.Executors +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Collection of tests around coroutines + */ +@UseExperimental(ExperimentalCoroutinesApi::class) +class CoroutineTest { + + /** + * Hooks onto the refresh channel for one true -> false cycle. + * Returns the list of event ids that were emitted + */ + private suspend fun transition(channel: ReceiveChannel>): List> { + var refreshed = false + return listen(channel) { (refreshing, _) -> + if (refreshed && !refreshing) + return@listen true + if (refreshing) + refreshed = true + return@listen false + } + } + + private suspend fun listen(channel: ReceiveChannel, shouldEnd: suspend (T) -> Boolean = { false }): List = + withContext(Dispatchers.IO) { + val data = mutableListOf() + for (c in channel) { + data.add(c) + if (shouldEnd(c)) break + } + channel.cancel() + return@withContext data + } + + /** + * When refreshing, we have a temporary subscriber that hooks onto a single cycle. + * The refresh channel only contains booleans, but for the sake of identification, + * each boolean will have a unique integer attached. + * + * Things to note: + * Subscription should be opened outside of async, since we don't want to miss any events. + */ + @Test + fun refreshSubscriptions() { + val refreshChannel = BroadcastChannel>(100) + runBlocking { + // Listen to all events + val fullReceiver = refreshChannel.openSubscription() + val fullDeferred = async { listen(fullReceiver) } + + refreshChannel.send(true to 1) + refreshChannel.send(false to 2) + refreshChannel.send(true to 3) + + val partialReceiver = refreshChannel.openSubscription() + val partialDeferred = async { transition(partialReceiver) } + refreshChannel.send(false to 4) + refreshChannel.send(true to 5) + refreshChannel.send(false to 6) + refreshChannel.send(true to 7) + refreshChannel.close() + val fullStream = fullDeferred.await() + val partialStream = partialDeferred.await() + + assertEquals( + 7, + fullStream.size, + "Full stream should contain all events" + ) + assertEquals( + listOf(false to 4, true to 5, false to 6), + partialStream, + "Partial stream should include up until first true false pair" + ) + } + } + + /** + * Sanity check to ensure that contexts are being honoured + */ + @Test + fun contextSwitching() { + val mainTag = "main-test" + val mainDispatcher = Executors.newSingleThreadExecutor { r -> + Thread(r, mainTag) + }.asCoroutineDispatcher() + + val channel = BroadcastChannel(100) + + runBlocking(Dispatchers.IO) { + val receiver1 = channel.openSubscription() + val receiver2 = channel.openSubscription() + launch(mainDispatcher) { + for (thread in receiver1) { + assertTrue( + Thread.currentThread().name.startsWith(mainTag), + "Channel should be received in main thread" + ) + assertFalse( + thread.startsWith(mainTag), + "Channel execution should not be in main thread" + ) + } + } + listOf(EmptyCoroutineContext, Dispatchers.IO, Dispatchers.Default, Dispatchers.IO).map { + async(it) { channel.send(Thread.currentThread().name) } + }.joinAll() + channel.close() + assertEquals(4, receiver2.count(), "Not all events received") + } + } + + /** + * Not a true throttle, but for things like fetching header badges, we want to avoid simultaneous fetches. + * As a result, I want to test that the usage of offer along with a rendezvous channel will work as I expect. + * Events should be consumed when there is no pending consumer on previous elements. + */ + @Test + fun throttledChannel() { + val channel = Channel(Channel.RENDEZVOUS) + runBlocking { + val deferred = async { + listen(channel) { + // Throttle consumer + delay(10) + return@listen false + } + } + (0..100).forEach { + channel.offer(it) + delay(1) + } + channel.close() + val received = deferred.await() + assertTrue( + received.size < 20, + "Received data should be throttled; expected that around 1/10th of all events are consumed" + ) + println(received) + } + } + + @Test + fun uniqueOnly() { + val channel = BroadcastChannel(100) + runBlocking { + val fullReceiver = channel.openSubscription() + val uniqueReceiver = channel.openSubscription().uniqueOnly(this) + + val fullDeferred = async { listen(fullReceiver) } + val uniqueDeferred = async { listen(uniqueReceiver) } + + listOf(0, 1, 2, 3, 3, 3, 4, 3, 5, 5, 1).forEach { + channel.offer(it) + } + channel.close() + + val fullData = fullDeferred.await() + val uniqueData = uniqueDeferred.await() + + assertEquals( + listOf(0, 1, 2, 3, 3, 3, 4, 3, 5, 5, 1), + fullData, + "Full receiver should get all channel events" + ) + assertEquals( + listOf(0, 1, 2, 3, 4, 3, 5, 1), + uniqueData, + "Unique receiver should not have two consecutive events that are equal" + ) + + } + } +} \ No newline at end of file diff --git a/app/src/test/kotlin/com/pitchedapps/frost/views/FrostContentViewAsyncTest.kt b/app/src/test/kotlin/com/pitchedapps/frost/views/FrostContentViewAsyncTest.kt deleted file mode 100644 index 13c40aa3..00000000 --- a/app/src/test/kotlin/com/pitchedapps/frost/views/FrostContentViewAsyncTest.kt +++ /dev/null @@ -1,130 +0,0 @@ -package com.pitchedapps.frost.views - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.asCoroutineDispatcher -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.BroadcastChannel -import kotlinx.coroutines.channels.ReceiveChannel -import kotlinx.coroutines.channels.count -import kotlinx.coroutines.joinAll -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import java.util.concurrent.Executors -import kotlin.coroutines.EmptyCoroutineContext -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Collection of tests around the view thread logic - */ -@UseExperimental(ExperimentalCoroutinesApi::class) -class FrostContentViewAsyncTest { - - /** - * Hooks onto the refresh channel for one true -> false cycle. - * Returns the list of event ids that were emitted - */ - private suspend fun transition(channel: ReceiveChannel>): List> { - var refreshed = false - return listen(channel) { (refreshing, _) -> - if (refreshed && !refreshing) - return@listen true - if (refreshing) - refreshed = true - return@listen false - } - } - - private suspend fun listen(channel: ReceiveChannel, shouldEnd: (T) -> Boolean = { false }): List = - withContext(Dispatchers.IO) { - val data = mutableListOf() - for (c in channel) { - data.add(c) - if (shouldEnd(c)) break - } - channel.cancel() - return@withContext data - } - - /** - * When refreshing, we have a temporary subscriber that hooks onto a single cycle. - * The refresh channel only contains booleans, but for the sake of identification, - * each boolean will have a unique integer attached. - * - * Things to note: - * Subscription should be opened outside of async, since we don't want to miss any events. - */ - @Test - fun refreshSubscriptions() { - val refreshChannel = BroadcastChannel>(100) - runBlocking { - // Listen to all events - val fullReceiver = refreshChannel.openSubscription() - val fullDeferred = async { listen(fullReceiver) } - - refreshChannel.send(true to 1) - refreshChannel.send(false to 2) - refreshChannel.send(true to 3) - - val partialReceiver = refreshChannel.openSubscription() - val partialDeferred = async { transition(partialReceiver) } - refreshChannel.send(false to 4) - refreshChannel.send(true to 5) - refreshChannel.send(false to 6) - refreshChannel.send(true to 7) - refreshChannel.close() - val fullStream = fullDeferred.await() - val partialStream = partialDeferred.await() - - assertEquals( - 7, - fullStream.size, - "Full stream should contain all events" - ) - assertEquals( - listOf(false to 4, true to 5, false to 6), - partialStream, - "Partial stream should include up until first true false pair" - ) - } - } - - /** - * Sanity check to ensure that contexts are being honoured - */ - @Test - fun contextSwitching() { - val mainTag = "main-test" - val mainDispatcher = Executors.newSingleThreadExecutor { r -> - Thread(r, mainTag) - }.asCoroutineDispatcher() - - val channel = BroadcastChannel(100) - - runBlocking(Dispatchers.IO) { - val receiver1 = channel.openSubscription() - val receiver2 = channel.openSubscription() - launch(mainDispatcher) { - for (thread in receiver1) { - assertTrue( - Thread.currentThread().name.startsWith(mainTag), - "Channel should be received in main thread" - ) - assertFalse( - thread.startsWith(mainTag), - "Channel execution should not be in main thread" - ) - } - } - listOf(EmptyCoroutineContext, Dispatchers.IO, Dispatchers.Default, Dispatchers.IO).map { - async(it) { channel.send(Thread.currentThread().name) } - }.joinAll() - channel.close() - assertEquals(4, receiver2.count(), "Not all events received") - } - } -} \ No newline at end of file -- cgit v1.2.3