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

Create intermediate sourceset to share between Apple and non-Apple targets #45

Merged
merged 12 commits into from
Feb 19, 2022
Merged
10 changes: 8 additions & 2 deletions kmp-nativecoroutines-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ kotlin {
implementation(Dependencies.Kotlinx.atomicfu)
}
}
val appleMain by creating {
val nativeCoroutinesMain by creating {
dependsOn(commonMain)
}
val appleTest by creating {
val nativeCoroutinesTest by creating {
dependsOn(commonTest)
}
val appleMain by creating {
dependsOn(nativeCoroutinesMain)
}
val appleTest by creating {
dependsOn(nativeCoroutinesTest)
}
listOf(
macosX64, macosArm64,
iosArm64, iosX64, iosSimulatorArm64,
Expand Down
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,8 @@ import platform.Foundation.NSError
import platform.Foundation.NSLocalizedDescriptionKey
import kotlin.native.concurrent.freeze

actual typealias NativeError = NSError

/**
* Converts a [Throwable] to a [NSError].
*
Expand All @@ -15,12 +17,12 @@ import kotlin.native.concurrent.freeze
* The Kotlin throwable can be retrieved from the [NSError.userInfo] with the key `KotlinException`.
*/
@OptIn(UnsafeNumber::class)
internal fun Throwable.asNSError(): NSError {
internal actual fun Throwable.asNativeError(): NativeError {
val userInfo = mutableMapOf<Any?, Any>()
userInfo["KotlinException"] = this.freeze()
val message = message
if (message != null) {
userInfo[NSLocalizedDescriptionKey] = message
}
return NSError.errorWithDomain("KotlinException", 0.convert(), userInfo)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,26 @@ package com.rickclephas.kmp.nativecoroutines

import kotlinx.cinterop.UnsafeNumber
import kotlinx.cinterop.convert
import kotlin.native.concurrent.isFrozen
import kotlin.test.*

class NSErrorTests {

@Test
fun `ensure frozen`() {
val exception = RandomException()
assertFalse(exception.isFrozen, "Exception shouldn't be frozen yet")
val nsError = exception.asNSError()
assertTrue(nsError.isFrozen, "NSError should be frozen")
assertTrue(exception.isFrozen, "Exception should be frozen")
}
internal actual val NativeError.kotlinCause
get() = this.userInfo["KotlinException"] as? Throwable

class NSErrorTests {
@Test
@OptIn(UnsafeNumber::class)
fun `ensure NSError domain and code are correct`() {
val exception = RandomException()
val nsError = exception.asNSError()
val nsError = exception.asNativeError()
assertEquals("KotlinException", nsError.domain, "Incorrect NSError domain")
assertEquals(0.convert(), nsError.code, "Incorrect NSError code")
}

@Test
fun `ensure localizedDescription is set to message`() {
val exception = RandomException()
val nsError = exception.asNSError()
val nsError = exception.asNativeError()
assertEquals(exception.message, nsError.localizedDescription,
"Localized description isn't set to message")
}

@Test
fun `ensure exception is part of user info`() {
val exception = RandomException()
val nsError = exception.asNSError()
assertSame(exception, nsError.userInfo["KotlinException"], "Exception isn't part of the user info")
}
}
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
@@ -0,0 +1,11 @@
package com.rickclephas.kmp.nativecoroutines

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

/**
* Converts a [Throwable] to a [NativeError].
*/
internal expect fun Throwable.asNativeError(): NativeError
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<NativeError?>) -> 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<NativeError?> ->
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.asNativeError())
}
}
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.asNativeError())
}
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<NativeError>) -> 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<NativeError> ->
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.asNativeError())
}
}
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.asNativeError())
}
return@collect job.asNativeCancellable()
}).freeze()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.rickclephas.kmp.nativecoroutines

import kotlin.native.concurrent.isFrozen
import kotlin.test.*

/**
* Get the [Throwable] that is represented by the given [NativeError]
*/
internal expect val NativeError.kotlinCause: Throwable?

class NativeErrorTests {
@Test
fun `ensure frozen`() {
val exception = RandomException()
assertFalse(exception.isFrozen, "Exception shouldn't be frozen yet")
val nsError = exception.asNativeError()
assertTrue(nsError.isFrozen, "NSError should be frozen")
assertTrue(exception.isFrozen, "Exception should be frozen")
}

@Test
fun `ensure exception is part of user info`() {
val exception = RandomException()
val nsError = exception.asNativeError()
assertSame(exception, nsError.kotlinCause, "Exception isn't part of the NativeError")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ class NativeFlowTests {

@Test
fun `ensure frozen`() {
val flow = flow<RandomValue> { }
val flow = flow<RandomValue> { }
assertFalse(flow.isFrozen, "Flow shouldn't be frozen yet")
val nativeFlow = flow.asNativeFlow()
assertTrue(nativeFlow.isFrozen, "NativeFlow should be frozen")
assertTrue(flow.isFrozen, "Flow should be frozen")
}

@Test
fun `ensure completion callback is invoked`() = runBlocking {
val flow = flow<RandomValue> { }
fun `ensure completion callback is invoked`() = kotlinx.coroutines.runBlocking {
val flow = flow<RandomValue> { }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
val completionCount = atomic(0)
Expand All @@ -33,15 +33,15 @@ class NativeFlowTests {
}

@Test
fun `ensure exceptions are received as errors`() = runBlocking {
fun `ensure exceptions are received as errors`() = kotlinx.coroutines.runBlocking {
val exception = RandomException()
val flow = flow<RandomValue> { throw exception }
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
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.incrementAndGet()
})
Expand All @@ -50,7 +50,7 @@ class NativeFlowTests {
}

@Test
fun `ensure values are received`() = runBlocking {
fun `ensure values are received`() = kotlinx.coroutines.runBlocking {
val values = listOf(RandomValue(), RandomValue(), RandomValue(), RandomValue())
val flow = flow { values.forEach { emit(it) } }
val job = Job()
Expand All @@ -61,18 +61,22 @@ class NativeFlowTests {
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")
assertEquals(
values.size,
receivedValueCount.value,
"Item callback should be called for every value"
)
}

@Test
fun `ensure collection is cancelled`() = runBlocking {
fun `ensure collection is cancelled`() = kotlinx.coroutines.runBlocking {
val flow = MutableSharedFlow<RandomValue>()
val job = Job()
val nativeFlow = flow.asNativeFlow(CoroutineScope(job))
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.incrementAndGet()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ 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.isFrozen
import kotlin.test.*
Expand All @@ -31,7 +30,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure correct result is received`() = runBlocking {
fun `ensure correct result is received`() = kotlinx.coroutines.runBlocking {
val value = RandomValue()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(100, value) }
Expand All @@ -49,7 +48,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure exceptions are received as errors`() = runBlocking {
fun `ensure exceptions are received as errors`() = kotlinx.coroutines.runBlocking {
val exception = RandomException()
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndThrow(100, exception) }
Expand All @@ -59,7 +58,7 @@ class NativeSuspendTests {
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.incrementAndGet()
})
Expand All @@ -69,7 +68,7 @@ class NativeSuspendTests {
}

@Test
fun `ensure function is cancelled`() = runBlocking {
fun `ensure function is cancelled`() = kotlinx.coroutines.runBlocking {
val job = Job()
val nativeSuspend = nativeSuspend(CoroutineScope(job)) { delayAndReturn(5_000, RandomValue()) }
val receivedResultCount = atomic(0)
Expand All @@ -78,7 +77,7 @@ class NativeSuspendTests {
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.incrementAndGet()
})
Expand Down