Skip to content

Commit

Permalink
Create interop-coroutines module (missing tests)
Browse files Browse the repository at this point in the history
  • Loading branch information
mapm14 committed Feb 5, 2024
1 parent 8cc35ab commit 1150d68
Show file tree
Hide file tree
Showing 8 changed files with 273 additions and 0 deletions.
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ include(
":demos:sandbox-appyx-navigation:web",
":ksp:mutable-ui-processor",
":utils:customisations",
":utils:interop-coroutines",
":utils:interop-ribs",
":utils:interop-rx2",
":utils:interop-rx3",
Expand Down
55 changes: 55 additions & 0 deletions utils/interop-coroutines/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
plugins {
id("com.bumble.appyx.multiplatform")
id("com.android.library")
id("appyx-publish-multiplatform")
}

appyx {
androidNamespace.set("com.bumble.appyx.utils.interop.coroutines")
}

kotlin {
androidTarget {
publishLibraryVariants("release")
}
jvm("desktop") {
compilations.all {
kotlinOptions.jvmTarget = libs.versions.jvmTarget.get()
}
}
js(IR) {
// Adding moduleName as a workaround for this issue: https://youtrack.jetbrains.com/issue/KT-51942
moduleName = "appyx-utils-coroutines"
browser()
}

iosX64()
iosArm64()
iosSimulatorArm64()

sourceSets {
val commonMain by getting {
dependencies {
api(project(":appyx-navigation:appyx-navigation"))
}
}
val commonTest by getting {
dependencies {
implementation(kotlin("test"))
implementation(libs.kotlin.coroutines.test)
}
}
val androidMain by getting
val desktopMain by getting
val jsMain by getting
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
}
}
}
4 changes: 4 additions & 0 deletions utils/interop-coroutines/lint-baseline.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 7.3.1" type="baseline" client="gradle" dependencies="false" name="AGP (7.3.1)" variant="all" version="7.3.1">

</issues>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.bumble.appyx.utils.interop.coroutines.connectable

import kotlinx.coroutines.flow.MutableSharedFlow

interface Connectable<Input, Output> {
val input: MutableSharedFlow<Input>
val output: MutableSharedFlow<Output>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package com.bumble.appyx.utils.interop.coroutines.connectable

import com.bumble.appyx.navigation.lifecycle.Lifecycle
import com.bumble.appyx.navigation.plugin.NodeLifecycleAware
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

class NodeConnector<Input, Output : Any> : Connectable<Input, Output>, NodeLifecycleAware {

override fun onCreate(lifecycle: Lifecycle) {
flushInput()
flushOutput()
}

// region Input
private fun flushInput() {
if (!isInputFlushed) {
val coroutineScope = CoroutineScope(Dispatchers.Default + Job())
_inputReplayCache.forEach {
coroutineScope.launch { _input.emit(it) }
}
}
isInputFlushed = true
_inputReplayCache.clear()
}

private var isInputFlushed = false
private val _input = MutableSharedFlow<Input>()
private val _inputReplayCache = mutableListOf<Input>()
override val input: MutableSharedFlow<Input> = object : MutableSharedFlow<Input> {

override val replayCache: List<Input> = _inputReplayCache

override val subscriptionCount: StateFlow<Int> = _input.subscriptionCount

@OptIn(ExperimentalCoroutinesApi::class)
override fun resetReplayCache() {
_inputReplayCache.clear()
}

override fun tryEmit(value: Input): Boolean =
if (isInputFlushed) {
_input.tryEmit(value)
} else {
_inputReplayCache.add(value)
}

override suspend fun emit(value: Input) {
if (isInputFlushed) {
_input.emit(value)
} else {
_inputReplayCache.add(value)
}
}

override suspend fun collect(collector: FlowCollector<Input>): Nothing =
_input.collect(collector)
}
// endregion

// region Output
private fun flushOutput() {
if (!isOutputFlushed) {
val coroutineScope = CoroutineScope(Dispatchers.Default + Job())
_outputReplayCache.forEach {
coroutineScope.launch { _output.emit(it) }
}
}
isOutputFlushed = true
_outputReplayCache.clear()
}

private var isOutputFlushed = false
private val _output = MutableSharedFlow<Output>()
private val _outputReplayCache = mutableListOf<Output>()
override val output: MutableSharedFlow<Output> = object : MutableSharedFlow<Output> {

override val replayCache: List<Output> = _outputReplayCache

override val subscriptionCount: StateFlow<Int> = _output.subscriptionCount

@OptIn(ExperimentalCoroutinesApi::class)
override fun resetReplayCache() {
_outputReplayCache.clear()
}

override fun tryEmit(value: Output): Boolean =
if (isOutputFlushed) {
_output.tryEmit(value)
} else {
_outputReplayCache.add(value)
}

override suspend fun emit(value: Output) {
if (isOutputFlushed) {
_output.emit(value)
} else {
_outputReplayCache.add(value)
}
}

override suspend fun collect(collector: FlowCollector<Output>): Nothing =
_output.collect(collector)
}
// endregion

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.bumble.appyx.utils.interop.coroutines.plugin

import com.bumble.appyx.interactions.core.plugin.Plugin
import com.bumble.appyx.navigation.plugin.Destroyable
import kotlinx.coroutines.Job

private class DisposeOnDestroy(private val jobs: List<Job>) : Destroyable {
override fun destroy() {
jobs.forEach { it.cancel() }
}
}

fun disposeOnDestroyPlugin(vararg jobs: Job): Plugin = DisposeOnDestroy(jobs.toList())
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.bumble.appyx.utils.interop.coroutines.store

import com.bumble.appyx.navigation.modality.NodeContext
import com.bumble.appyx.navigation.store.RetainedInstanceStore
import com.bumble.appyx.navigation.store.getRetainedInstance
import kotlinx.coroutines.Job

/**
* Obtains or creates an instance of a class via the [get] extension.
* The Job will be cancelled when the disposer function is called.
*/
inline fun <reified T : Job> RetainedInstanceStore.getJob(
storeId: String,
key: String,
noinline factory: () -> T
): T = get(
storeId = storeId,
disposer = { it.cancel() },
factory = factory,
key = key,
)

/**
* Obtains or creates an instance of a class via the [getRetainedInstance] extension.
* The Job will be cancelled when the disposer function is called.
*/
inline fun <reified T : Job> NodeContext.getRetainedDisposable(
key: String,
noinline factory: () -> T
) = getRetainedInstance(
key = key,
disposer = { it.cancel() },
factory = factory,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.bumble.appyx.utils.interop.coroutines.plugin

import com.bumble.appyx.navigation.plugin.Destroyable
import kotlinx.coroutines.Job
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.test.Test

internal class CoroutinesDisposeOnDestroyTest {
@Test
fun `WHEN dispose on destroy plugin created THEN verify is destroyable type`() {
assertIs<Destroyable>(disposeOnDestroyPlugin())
}

@Test
fun `GIVEN dispose on destroy plugin created with job WHEN destroy THEN job is cancelled`() {
val job = Job()
val disposeOnDestroyPlugin = disposeOnDestroyPlugin(job)

(disposeOnDestroyPlugin as Destroyable).destroy()

assertTrue(job.isCancelled)
}

@Test
fun `GIVEN dispose on destroy plugin created with multiple jobs WHEN destroy THEN all jobs are cancelled`() {
val job1 = Job()
val job2 = Job()
val disposeOnDestroyPlugin = disposeOnDestroyPlugin(job1, job2)

(disposeOnDestroyPlugin as Destroyable).destroy()

assertTrue(job1.isCancelled)
assertTrue(job2.isCancelled)
}

@Test
fun `WHEN dispose on destroy plugin created with job THEN job is not cancelled`() {
val job = Job()
disposeOnDestroyPlugin(job)

assertFalse(job.isCancelled)
}
}

0 comments on commit 1150d68

Please sign in to comment.