diff --git a/settings.gradle.kts b/settings.gradle.kts index 03bcb2d8d..fcdc2d240 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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", diff --git a/utils/interop-coroutines/build.gradle.kts b/utils/interop-coroutines/build.gradle.kts new file mode 100644 index 000000000..913380acf --- /dev/null +++ b/utils/interop-coroutines/build.gradle.kts @@ -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) + } + } +} diff --git a/utils/interop-coroutines/lint-baseline.xml b/utils/interop-coroutines/lint-baseline.xml new file mode 100644 index 000000000..27ab162a6 --- /dev/null +++ b/utils/interop-coroutines/lint-baseline.xml @@ -0,0 +1,4 @@ + + + + diff --git a/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/Connectable.kt b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/Connectable.kt new file mode 100644 index 000000000..b375305cc --- /dev/null +++ b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/Connectable.kt @@ -0,0 +1,8 @@ +package com.bumble.appyx.utils.interop.coroutines.connectable + +import kotlinx.coroutines.flow.MutableSharedFlow + +interface Connectable { + val input: MutableSharedFlow + val output: MutableSharedFlow +} diff --git a/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/NodeConnector.kt b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/NodeConnector.kt new file mode 100644 index 000000000..ddf4d4687 --- /dev/null +++ b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/connectable/NodeConnector.kt @@ -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 : Connectable, 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() + private val _inputReplayCache = mutableListOf() + override val input: MutableSharedFlow = object : MutableSharedFlow { + + override val replayCache: List = _inputReplayCache + + override val subscriptionCount: StateFlow = _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): 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() + private val _outputReplayCache = mutableListOf() + override val output: MutableSharedFlow = object : MutableSharedFlow { + + override val replayCache: List = _outputReplayCache + + override val subscriptionCount: StateFlow = _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): Nothing = + _output.collect(collector) + } + // endregion + +} diff --git a/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/DisposeOnDestroy.kt b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/DisposeOnDestroy.kt new file mode 100644 index 000000000..b32dea888 --- /dev/null +++ b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/DisposeOnDestroy.kt @@ -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) : Destroyable { + override fun destroy() { + jobs.forEach { it.cancel() } + } +} + +fun disposeOnDestroyPlugin(vararg jobs: Job): Plugin = DisposeOnDestroy(jobs.toList()) diff --git a/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/store/RetainedInstanceStoreExt.kt b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/store/RetainedInstanceStoreExt.kt new file mode 100644 index 000000000..57c3d9bf8 --- /dev/null +++ b/utils/interop-coroutines/src/commonMain/kotlin/com/bumble/appyx/utils/interop/coroutines/store/RetainedInstanceStoreExt.kt @@ -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 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 NodeContext.getRetainedDisposable( + key: String, + noinline factory: () -> T +) = getRetainedInstance( + key = key, + disposer = { it.cancel() }, + factory = factory, +) diff --git a/utils/interop-coroutines/src/test/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/CoroutinesDisposeOnDestroyTest.kt b/utils/interop-coroutines/src/test/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/CoroutinesDisposeOnDestroyTest.kt new file mode 100644 index 000000000..0dc835086 --- /dev/null +++ b/utils/interop-coroutines/src/test/kotlin/com/bumble/appyx/utils/interop/coroutines/plugin/CoroutinesDisposeOnDestroyTest.kt @@ -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(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) + } +}