Skip to content
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,8 @@ object Dependencies {

object Kotlinx {
const val coroutinesCore = "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0-native-mt"
const val coroutinesTest = "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.0-native-mt"

const val atomicfu = "org.jetbrains.kotlinx:atomicfu:0.17.1"
}
}
19 changes: 17 additions & 2 deletions kmp-nativecoroutines-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,23 @@ kotlin {
val commonTest by getting {
dependencies {
implementation(kotlin("test"))

implementation(Dependencies.Kotlinx.coroutinesTest)
implementation(Dependencies.Kotlinx.atomicfu)
}
}
val appleMain by creating {
val supportedTargetMain by creating {
dependsOn(commonMain)
}
val appleTest by creating {
val supportedTargetTest by creating {
dependsOn(commonTest)
}
val appleMain by creating {
dependsOn(supportedTargetMain)
}
val appleTest by creating {
dependsOn(supportedTargetTest)
}
listOf(
macosX64, macosArm64,
iosArm64, iosX64, iosSimulatorArm64,
Expand All @@ -56,5 +65,11 @@ kotlin {
dependsOn(appleTest)
}
}
val jsMain by getting {
dependsOn(supportedTargetMain)
}
val jsTest by getting {
dependsOn(supportedTargetTest)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

actual fun <T> T.freeze(): T = this.freeze()
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import platform.Foundation.NSError
import platform.Foundation.NSLocalizedDescriptionKey
import kotlin.native.concurrent.freeze

actual typealias PlatformError = NSError

internal actual fun Throwable.asPlatformError(): PlatformError = this.asNSError()

actual val PlatformError.kotlinCause
get() = this.userInfo["KotlinException"] as? Throwable

/**
* Converts a [Throwable] to a [NSError].
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.rickclephas.kmp.nativecoroutines
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeCallbackTests {
class AppleNativeCallbackTests {

@Test
fun `ensure frozen`() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue

class NativeCancellableTests {
class AppleNativeCancellableTests {

@Test
fun `ensure frozen`() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.atomicfu.atomic
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.flow
import kotlin.native.concurrent.AtomicInt
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeFlowTests {
class AppleNativeFlowTests {

@Test
fun `ensure frozen`() {
Expand All @@ -23,10 +23,10 @@ class NativeFlowTests {
val flow = flow<RandomValue> { }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
nativeFlow({ _, _ -> }, { error, _ ->
assertNull(error, "Flow should complete without an error")
completionCount.increment()
completionCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(1, completionCount.value, "Completion callback should be called once")
Expand All @@ -38,12 +38,12 @@ class NativeFlowTests {
val flow = flow<RandomValue> { throw exception }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
completionCount.increment()
completionCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(1, completionCount.value, "Completion callback should be called once")
Expand All @@ -55,10 +55,10 @@ class NativeFlowTests {
val flow = flow { values.forEach { emit(it) } }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val receivedValueCount = AtomicInt(0)
val receivedValueCount = atomic(0)
nativeFlow({ value, _ ->
assertSame(values[receivedValueCount.value], value, "Received incorrect value")
receivedValueCount.increment()
receivedValueCount.incrementAndGet()
}, { _, _ -> })
job.children.forEach { it.join() } // Waits for the collection to complete
assertEquals(values.size, receivedValueCount.value, "Item callback should be called for every value")
Expand All @@ -69,12 +69,12 @@ class NativeFlowTests {
val flow = MutableSharedFlow<RandomValue>()
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = AtomicInt(0)
val completionCount = atomic(0)
val cancel = nativeFlow({ _, _ -> }, { error, _ ->
assertNotNull(error, "Flow should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
completionCount.increment()
completionCount.incrementAndGet()
})
delay(100) // Gives the collection some time to start
cancel()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.atomicfu.atomic
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.cancellation.CancellationException
import kotlin.native.concurrent.AtomicInt
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NativeSuspendTests {
class AppleNativeSuspendTests {

private suspend fun delayAndReturn(delay: Long, value: RandomValue): RandomValue {
delay(delay)
Expand All @@ -35,13 +35,13 @@ class NativeSuspendTests {
val value = RandomValue()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(100, value) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
nativeSuspend({ receivedValue, _ ->
assertSame(value, receivedValue, "Received incorrect value")
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { _, _ ->
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the function to complete
assertEquals(1, receivedResultCount.value, "Result callback should be called once")
Expand All @@ -53,15 +53,15 @@ class NativeSuspendTests {
val exception = RandomException()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndThrow(100, exception) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
nativeSuspend({ _, _ ->
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val kotlinException = error.userInfo["KotlinException"]
val kotlinException = error.kotlinCause
assertSame(exception, kotlinException, "Kotlin exception should be the same exception")
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
job.children.forEach { it.join() } // Waits for the function to complete
assertEquals(1, receivedErrorCount.value, "Error callback should be called once")
Expand All @@ -72,15 +72,15 @@ class NativeSuspendTests {
fun `ensure function is cancelled`() = runBlocking {
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(5_000, RandomValue()) }
val receivedResultCount = AtomicInt(0)
val receivedErrorCount = AtomicInt(0)
val receivedResultCount = atomic(0)
val receivedErrorCount = atomic(0)
val cancel = nativeSuspend({ _, _ ->
receivedResultCount.increment()
receivedResultCount.incrementAndGet()
}, { error, _ ->
assertNotNull(error, "Function should complete with an error")
val exception = error.userInfo["KotlinException"]
val exception = error.kotlinCause
assertIs<CancellationException>(exception, "Error should contain CancellationException")
receivedErrorCount.increment()
receivedErrorCount.incrementAndGet()
})
delay(100) // Gives the function some time to start
cancel()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

/**
* Freezing is a no-op on JS
*/
internal actual fun <T> T.freeze() = this
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

actual typealias PlatformError = Throwable
actual fun Throwable.asPlatformError() = this
actual val PlatformError.kotlinCause: Throwable?
get() = this
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package com.rickclephas.kmp.nativecoroutines

internal expect fun <T> T.freeze(): T
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.freeze

/**
* A callback with a single argument.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package com.rickclephas.kmp.nativecoroutines

import kotlinx.coroutines.Job
import kotlin.native.concurrent.freeze

/**
* A function that cancels the coroutines [Job].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that collects a [Flow] via callbacks.
*
* The function takes an `onItem` and `onComplete` callback
* and returns a cancellable that can be used to cancel the collection.
*/
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?>) -> NativeCancellable
typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback<PlatformError?>) -> NativeCancellable

/**
* Creates a [NativeFlow] for this [Flow].
Expand All @@ -25,7 +23,7 @@ typealias NativeFlow<T> = (onItem: NativeCallback<T>, onComplete: NativeCallback
*/
fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<NSError?> ->
return (collect@{ onItem: NativeCallback<T>, onComplete: NativeCallback<PlatformError?> ->
val job = coroutineScope.launch {
try {
collect { onItem(it) }
Expand All @@ -35,13 +33,13 @@ fun <T> Flow<T>.asNativeFlow(scope: CoroutineScope? = null): NativeFlow<T> {
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onComplete(e.asNSError())
onComplete(e.asPlatformError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onComplete(cause.asNSError())
onComplete(cause.asPlatformError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ package com.rickclephas.kmp.nativecoroutines
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import platform.Foundation.NSError
import kotlin.native.concurrent.freeze

/**
* A function that awaits a suspend function via callbacks.
*
* The function takes an `onResult` and `onError` callback
* and returns a cancellable that can be used to cancel the suspend function.
*/
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<NSError>) -> NativeCancellable
typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallback<PlatformError>) -> NativeCancellable

/**
* Creates a [NativeSuspend] for the provided suspend [block].
Expand All @@ -22,7 +20,7 @@ typealias NativeSuspend<T> = (onResult: NativeCallback<T>, onError: NativeCallba
*/
fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): NativeSuspend<T> {
val coroutineScope = scope ?: defaultCoroutineScope
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<NSError> ->
return (collect@{ onResult: NativeCallback<T>, onError: NativeCallback<PlatformError> ->
val job = coroutineScope.launch {
try {
onResult(block())
Expand All @@ -31,13 +29,13 @@ fun <T> nativeSuspend(scope: CoroutineScope? = null, block: suspend () -> T): Na
// this is required since the job could be cancelled before it is started
throw e
} catch (e: Throwable) {
onError(e.asNSError())
onError(e.asPlatformError())
}
}
job.invokeOnCompletion { cause ->
// Only handle CancellationExceptions, all other exceptions should be handled inside the job
if (cause !is CancellationException) return@invokeOnCompletion
onError(cause.asNSError())
onError(cause.asPlatformError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.rickclephas.kmp.nativecoroutines

/**
* Represents an error in a way that the specific platform is able to handle
*/
expect class PlatformError

/**
* Converts a [Throwable] to a [PlatformError].
*/
internal expect fun Throwable.asPlatformError(): PlatformError

internal expect val PlatformError.kotlinCause: Throwable?
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.test.*

class NativeCallbackTests {
@Test
fun ensure_invoked() {
var invokeCount = 0
var receivedValue: RandomValue? = null
val callback: NativeCallback<RandomValue> = callback@{ value, unit ->
receivedValue = value
invokeCount++
// This isn't required in Kotlin but it is in Swift so we'll test it anyway
return@callback unit
}
val value = RandomValue()
callback(value)
assertEquals(1, invokeCount, "NativeCallback should have been invoked once")
assertSame(value, receivedValue, "Received value should be the same as the send value")
}
}
Loading